- commit
- 3f91b41b48e7b74ff731b855208d71124b6b5de1
- parent
- ae21fee38827702340fc6651633e85e8ba2951c4
- author
- Dami <dami.etoile@gmail.com>
| A | deno.json | 18 | ++ |
| A | docs/ | 20 | ++ |
| A | docs/ | 274 | ++++++++++++++++++++++++++++++ |
| A | init.md | 228 | +++++++++++++++++++++++++ |
| A | tools/ | 21 | ++ |
| A | tools/ | 53 | ++++++ |
| A | tools/ | 92 | ++++++++++ |
| A | tools/ | 76 | ++++++++ |
| A | tools/ | 62 | +++++++ |
| A | tools/ | 29 | +++ |
| A | tools/ | 33 | ++++ |
| A | tools/ | 36 | ++++ |
@@ -0,0 +1,18 @@
1+{2+ "tasks": {3+ "check:env": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env tools/check_env.ts",4+ "check:secrets": "deno run --allow-env=JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read --allow-run=git tools/check_secret_leaks.ts",5+ "check": "deno fmt --check && deno lint && deno check tools/*.ts && deno task test && deno task check:env && deno task check:secrets",6+ "test": "deno test --allow-read --allow-write=. --allow-run=git tools"7+ },8+ "fmt": {9+ "lineWidth": 100,10+ "semiColons": true,11+ "singleQuote": false12+ },13+ "lint": {14+ "rules": {15+ "tags": ["recommended"]16+ }17+ }18+}@@ -0,0 +1,20 @@
1+# Decision records2+3+Every M1 spike produces a decision record with this shape:4+5+1. **Question** — the falsifiable choice or platform claim.6+2. **Contract IDs** — promises exercised by the experiment.7+3. **Exact inputs** — package, runtime, browser, OS, and device versions.8+4. **Control** — the smallest vendor-supported baseline.9+5. **Procedure** — commands and manual steps someone else can repeat.10+6. **Evidence** — repository paths, raw-output checksums when useful, tested commit SHA, timestamp,11+ measurements, tests, and limitations.12+7. **Decision** — adopt, revise, defer, or reject.13+8. **Contract delta** — exact promises changed by the result.14+9. **Follow-up** — later issue, if the decision intentionally leaves work.15+16+Vendor and platform claims cite primary documentation, changelog/source locations, or retained17+device evidence. A search summary without a stable source is not evidence.18+19+A spike is complete when it supports a decision, including a negative decision. It is not complete20+merely because a demo rendered once.@@ -0,0 +1,274 @@
1+# lofi developer-experience contract2+3+Status: **v0 proposed**\4+Scope: **M0 contract and M1 feasibility**\5+Last reviewed: **2026-07-15**6+7+This is the product contract for lofi. Implementation choices are provisional until they satisfy8+these promises. Each M1 spike must cite the contract IDs it tested, retain repeatable evidence, and9+record a contract delta even when the result does not change the contract.10+11+The older `init.md` is research input, not an authoritative description of Jazz or browser behavior.12+13+## Status and merge rules14+15+- **proposed** — a falsifiable promise awaiting evidence.16+- **validated** — demonstrated by retained automated or device evidence.17+- **revised** — changed because evidence disproved or narrowed the original promise.18+- **deferred** — valuable but intentionally owned by a later milestone.19+- **rejected** — disproved or incompatible with the product direction.20+21+The promises marked **M1 gate: yes** must be validated, revised, or rejected before M1 merges. A22+post-M1 promise may remain proposed only when it has a named milestone and does not affect the23+honesty of the graduated prototype.24+25+## Two distinct journeys26+27+### North-star generated-project journey28+29+This is the eventual product experience. The generator itself is M2 work and is not claimed as an M130+deliverable.31+32+```sh33+deno run -A jsr:@lofi/create my-app34+cd my-app35+deno task dev36+```37+38+From there the developer makes a retained local write, reloads it, works offline, runs checks,39+builds, previews, and opens the same stable secure origin on a physical device.40+41+### M1 graduation journey42+43+M1 validates the stack and contract from a repository checkout, without pretending that the M244+generator exists:45+46+```sh47+git clone https://github.com/FelineStateMachine/lofi.git48+cd lofi49+deno task dev50+```51+52+The graduated `apps/prototype` must then support a local write, reload, offline edit, reconnect,53+check, test, build, and preview. Git is a contributor prerequisite for this repository journey; Deno54+remains the only required global runtime for a generated application.55+56+## End-to-end journey contract57+58+| Step | Developer action | Required result | Representative failure and recovery |59+| ------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |60+| Create | Run the create command with a project name. | A complete directory is produced without prompts and the next two commands are printed. | Existing/non-empty destination names the conflict and suggests a new path; no overwrite occurs. |61+| Configure | Start with no `.env`, or copy `.env.example` and provide the public cloud pair. | No config selects explicit local-only mode; a complete pair selects cloud mode. | Partial/invalid config stops boot, states that sync cannot start, and says which name to set or remove. |62+| Develop | Run `deno task dev`. | One local URL, storage mode, identity state, sync mode, and PWA readiness are printed. | Unsupported durable storage stops or requires explicit ephemeral opt-in; it never silently degrades. |63+| First write | Use the starter UI to create data. | UI updates without awaiting network; data survives reload or runtime recreation. | A failed durable write is visible in UI and diagnostics with impact and remediation. |64+| Reload/HMR | Reload and edit an island five times. | Identity and local data remain; client/subscription counts do not grow. | Duplicate runtime resources fail a development assertion with the responsible subsystem named. |65+| Offline | Disable network, read and edit, then reconnect. | Local behavior continues and observable sync eventually converges. | Unsupported or failed sync remains visible; no “synced” claim is fabricated. |66+| Check/test | Run `deno task check` and `deno task test`. | Static checks and deterministic local-first tests pass without hidden global tools in generated apps. | Failure prints the failing check and preserves browser/log evidence. |67+| Build/preview | Run `deno task build`, then `deno task preview`. | Production output is served through the supported Deno path. | npm-compat or adapter failure names the internal fallback; no undocumented user-facing Node command appears. |68+| Device | Open the printed stable HTTPS origin on a phone. | Secure-context, storage, install, and passkey capability are diagnosed. | An unstable relying-party ID is rejected before passkey creation and the stable-origin action is named. |69+70+## Measurement protocol71+72+- Record Deno, package, OS, browser, device, commit SHA, and whether caches were warm.73+- Time uncached creation separately from cached startup; dependency download is never hidden from74+ the uncached result.75+- For startup and HMR, retain five samples and report median plus slowest sample. Startup begins76+ when the command is invoked and ends when the usable URL is printed. HMR begins when a watched77+ file is saved and ends when the updated UI is observable.78+- A **retained local write** must survive a full page reload or explicit client recreation.79+- A **subscription leak** means the count after five edit/reload cycles exceeds the initial steady80+ count for the same mounted consumers.81+- Common configuration failures must be recoverable with one described edit and one rerun; record82+ whether recovery took more than 60 seconds.83+- Device results retain the manual checklist plus logs or screenshots; “worked once” is not84+ evidence.85+86+## Measurable promises87+88+| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |89+| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------- | ------- | -------- |90+| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 | no | proposed |91+| DX-CMD-01 | The public command surface is small. | create, dev, doctor, check, test, build, preview only. | #2 and #6 | yes | proposed |92+| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 | no | proposed |93+| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | proposed |94+| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | proposed |95+| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one subscription per consumer after five edits. | #5 and #6 | yes | proposed |96+| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | proposed |97+| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | proposed |98+| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | proposed |99+| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | proposed |100+| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | proposed |101+| DX-OBS-01 | Diagnostics expose only truthful signals. | Every state maps to documented vendor API or named lofi instrumentation. | #7 | yes | proposed |102+| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | proposed |103+| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | proposed |104+| DX-AUTH-01 | Identity wording matches custody/recovery. | UI distinguishes local identity, passkey backup/restore, and bearer recovery phrase. | #8 | yes | proposed |105+| DX-DEVICE-01 | M1 device/auth tests use a stable secure origin. | Tested origin preserves relying-party ID across browser and installed-app sessions. | #8 | yes | proposed |106+| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | proposed |107+| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Airplane-mode launch renders shell and data. | feasibility #4; full M3 | no | proposed |108+| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | proposed |109+| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | feasibility #7; M2 implementation | no | proposed |110+111+## Canonical command surface and output contract112+113+These are product outputs, not claims that every command is implemented at Contract v0. The M1114+prototype must implement `dev`, `check`, `test`, `build`, and `preview`; `create` and the complete115+`doctor` experience graduate in M2.116+117+| Command | Success output must contain | Failure output must contain |118+| ------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------- |119+| `deno run -A jsr:@lofi/create <name>` | Created path and exact `cd`/`deno task dev` next steps. | Conflicting input/path, unchanged-files guarantee, and corrective command. |120+| `deno task dev` | Usable URL plus storage, identity, sync, and PWA states. | Failed subsystem, developer impact, and one remediation. |121+| `deno task doctor` | Versioned capability/configuration table without values. | Invalid/unsupported item plus remediation; nonzero exit for blockers. |122+| `deno task check` | Checks run and concise pass summary. | First failing check and rerun command. |123+| `deno task test` | Suites, durations, and retained failure-artifact location. | Failing scenario and artifact location without fixed-sleep advice. |124+| `deno task build` | Output path, route count, and secret-scan result. | Failed internal tool/adapter and supported fallback action. |125+| `deno task preview` | Production URL and build identity. | Missing/stale build and exact build command. |126+127+Example successful development output:128+129+```text130+lofi dev131+Local: http://localhost:4321132+Storage: OPFS durable133+Identity: local-first; passkey backup not configured134+Sync: connected; durability detail unavailable135+PWA: development service worker disabled136+```137+138+Example configuration failure:139+140+```text141+lofi environment mode: invalid142+error: Cloud sync cannot start because configuration is incomplete; set JAZZ_SERVER_URL or remove143+the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.144+```145+146+## Generated-project layout147+148+```text149+my-app/150+├── deno.json151+├── astro.config.ts152+├── .env.example153+├── public/154+├── src/155+│ ├── schema.ts156+│ ├── app.ts157+│ ├── pages/158+│ └── islands/159+└── tests/160+```161+162+The exact layout may change during M1 only when verified toolchain behavior requires it or the163+change removes an author-facing concept.164+165+## Author boundary166+167+| Location | Default allowance | M1 qualification |168+| -------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |169+| Product UI/pages/islands | lofi components/hooks and application types only. | Raw Jazz, worker, transport, Workbox, and capability branching are forbidden. |170+| `schema.ts` | Application schema declarations. | Direct pinned Jazz 2 schema declarations are allowed in the M1 prototype until #7 determines whether a lofi-owned schema facade is honest and affordable. |171+| `app.ts` | Named lofi configuration and composition. | Raw vendor setup is allowed only inside spike controls, not the graduated prototype. |172+| Generated/internal runtime | Vendor clients, workers, storage, sync, auth, and PWA wiring. | May change across alpha pins without changing product UI. |173+| Escape-hatch module | Explicitly isolated unsupported vendor access. | Never generated by default; no compatibility promise before framework extraction. |174+175+## Safe defaults176+177+- **Schema:** use the smallest verified Jazz 2 surface during M1; do not invent a second schema178+ language before #7.179+- **Identity:** create or restore the verified local-first identity without a server round trip;180+ passkey backup is never mislabeled as conventional login.181+- **Sync:** no config means clearly reported local-only mode if supported; a complete public pair182+ enables the named managed adapter.183+- **Storage:** request the verified durable browser driver; unsupported capability stops boot or184+ requires an explicit development-only ephemeral opt-in.185+- **PWA:** prerendered shell and offline production behavior are the target, while service-worker186+ ownership remains outside Jazz storage/sync.187+- **Testing:** unit and browser controls use readiness signals; physical-device gaps stay explicit.188+- **Security:** generated examples contain names only; server credentials never enter client189+ projection, logs, QR codes, build output, or browser assets.190+191+## Environment contract192+193+The four current names are **spike inputs**, not graduated API. #7 must confirm, rename, or remove194+them before DX-ENV-01 can validate.195+196+| Name | Provisional classification | Contract |197+| ------------------- | -------------------------- | ----------------------------------------------------------------------------- |198+| `JAZZ_APP_ID` | client-visible identifier | May enter client config only after validation. |199+| `JAZZ_SERVER_URL` | client-visible endpoint | May enter the named adapter; raw product-UI use is forbidden. |200+| `JAZZ_ADMIN_SECRET` | server-only secret | Never projected, logged, generated with a value, or built into client assets. |201+| `BACKEND_SECRET` | server-only secret | Never projected, logged, generated with a value, or built into client assets. |202+203+The allowlisted loader reads an optional ignored `.env`; an explicitly set process value wins.204+Missing public configuration selects local-only mode if #7 verifies support. Partial or invalid205+cloud configuration is `invalid`, stops the relevant command, and cannot be projected into client206+config. Server-only values without client configuration remain isolated and produce a warning.207+208+Root-repository ignore and leak tests do not satisfy the generated-project/release-artifact portion209+of #3. That acceptance item remains open until the M1 prototype produces a build to inspect; the M2210+generator gets its own fixture inspection.211+212+## Escape-hatch policy213+214+- An escape hatch exists only for a capability blocked by the public contract and demonstrated by a215+ retained use case.216+- It is isolated from ordinary product files and named as unsupported.217+- It may expose pinned vendor APIs but receives no compatibility guarantee.218+- Promotion requires two independent consumers or a critical use case, automated coverage, and a219+ decision record describing the new maintenance obligation.220+- Node build fallback, raw Jazz access, memory storage, and custom service-worker ownership are not221+ public defaults.222+223+## Non-goals through M1224+225+- Publishing packages or the generator.226+- Designing a provider-neutral data abstraction.227+- Reimplementing Jazz schema, auth, permissions, storage, or sync.228+- Building the full inspector, test SDK, invitation UX, push support, or production update UX.229+- Claiming support for untested browsers/devices or hiding unavailable durability signals.230+- Optimizing bundle size before the selected integration works reliably.231+232+## M1 acceptance checklist233+234+- [ ] Optional `.env` and process precedence select the expected mode without printing values.235+- [ ] Exact Jazz alpha, Deno, Astro, Preact, browser, OS, and device versions are retained.236+- [ ] Vendor control proves local write, subscription, reload retention, and optional cloud sync.237+- [ ] Observability inventory maps each promised diagnostic to evidence or removes it.238+- [ ] Preact decision passes mount/update/unmount/recreate and five-cycle HMR checks.239+- [ ] Two Astro islands share exactly one client and update each other.240+- [ ] `deno task dev`, `check`, `test`, `build`, and `preview` work from the checkout.241+- [ ] Startup/HMR measurements follow the declared protocol.242+- [ ] OPFS matrix covers reload, browser/app termination, device restart, foreground/background,243+ multi-tab leadership/concurrent writes, and browser persistence API behavior on exact iPhone244+ tab/installed and Android tab/installed versions.245+- [ ] OPFS evidence distinguishes durable, unsupported, and memory fallback and ends with an246+ explicit raise-floor/degrade/Classic-Jazz/change-layer decision tree.247+- [ ] Identity matrix covers fresh profile, cleared site data, deleted passkey, sign-out, switching,248+ recovery phrase, same-ecosystem restore, and cross-platform restore in browser and installed249+ variants.250+- [ ] Auth evidence uses stable HTTPS/RP-ID, states that the recovery phrase is a bearer secret, and251+ decides first-run, backup reminder, recovery, and unrecoverable-loss UX wording.252+- [ ] Integrated prototype works offline, reloads retained data, and visibly reconnects.253+- [ ] Final build passes server-secret scanning and contains no development-only inspector.254+- [ ] Every M1-gated promise is validated, revised, or rejected with evidence.255+- [ ] Final contract and decisions identify all remaining post-M1 work without implying completion.256+257+## M1 falsification map258+259+| Issue | Primary contract IDs |260+| -------------------- | --------------------------------------------------------- |261+| #7 Jazz 2 baseline | DX-LOCAL-01, DX-DUR-01, DX-SYNC-01, DX-OBS-01, DX-TEST-01 |262+| #5 Preact strategy | DX-HMR-01, DX-AUTHOR-01, DX-LEAK-01 |263+| #6 Astro and Deno | DX-CMD-01, DX-START-01, DX-HMR-01, DX-BUILD-01 |264+| #4 OPFS devices | DX-DUR-01, DX-ERROR-01, DX-OFFLINE-01 |265+| #8 identity/recovery | DX-AUTH-01, DX-DEVICE-01 |266+267+## Graduation rule268+269+M1 graduates exactly one integrated `apps/prototype` containing the approved Jazz version, Preact270+strategy, Astro/Deno path, durability policy, and identity model. Rejected runtime alternatives are271+removed from the final tree; their evidence remains in decisions and merge-commit history.272+273+Package extraction, the generator, full inspector/testing SDK, and production PWA hardening remain274+M2 or later work.@@ -0,0 +1,228 @@
1+# lofi — Local-First Mobile PWA Meta-Framework (Prototype Plan)2+3+> **Status: research input, not the current product contract.** The executable developer-experience4+> contract lives in `docs/devx-contract.md`. M1 spikes are expected to correct or reject claims in5+> this draft as Jazz 2 and target-browser behavior are verified.6+7+Working name **`lofi`** (import paths like `@lofi/core`). Goal: validate that Jazz + Preact8+islands + Astro + Deno can be wrapped into an ergonomic framework where the UI always hydrates from9+local data, identity is passkey-only, and **the only network surface an app developer ever sees is10+the sync module's primitives/hooks**.11+12+Scope decisions: prototype-to-learn (not a published framework yet); Deno is the dev toolchain and13+shell host; sync backend is Jazz Cloud (no self-hosted server); **Jazz 2.0 alpha with OPFS,14+targeting Android Chrome 148+ / iOS Safari 16.4+ as hard floors**.15+16+## Stack17+18+| Layer | Choice | Why |19+| ------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |20+| Shell | **Astro 7 + @deno/astro-adapter**, all routes `prerender = true` | Prerendered app shell (instant paint, SW-precacheable); SSR of local CoValues is meaningless, so the adapter is just a first-class Deno server for the shell + a future escape hatch. Fresh 2.3 has no PWA tooling; a Preact SPA has no prerendered shell. |21+| UI runtime | **Preact 10 islands via `client:only="preact"`** | Skips SSR entirely — correct when all data hydrates from local storage. |22+| Data | **jazz-tools 2.0 alpha (pinned exact version), OPFS persistent mode** | ~5x faster than the 0.20.x IndexedDB backend. Persistence requires SharedWorker + Web Locks + MessageChannel; fallback is memory-only — hence the hard platform floors below. |23+| Sync backend | **Jazz Cloud** (managed WebSocket) | Zero-config; self-hosting later changes nothing client-side. |24+| Auth | **Jazz PasskeyAuth + 24-word passphrase recovery** | Jazz custodies the account secret in a resident WebAuthn credential; wrap, don't reinvent. PRF-derived E2E-at-rest is an optional stretch. |25+| PWA | **@vite-pwa/astro v1.2 (Workbox)** | SW's job stays deliberately small: shell precache + push readiness. The SW never touches sync or OPFS. Full prerender = full offline shell for free (precache only covers prerendered routes). |26+| Cross-island state | **@preact/signals + module-scope singleton client** | React/Preact context can't cross Astro island boundaries; a module singleton + signals sidestep it. No provider component in the public API. |27+| Toolchain | **Deno 2.9** task runner + server; Astro/Vite under npm-compat | Fallback if npm-compat friction appears: Node for `astro build` only, Deno for everything else. |28+29+## Platform floors (enforced, not advisory)30+31+Jazz 2.0's only fallback below these floors is memory-only storage, which silently loses data — so32+`@lofi/core`'s `detect()` is a **boot gate**: unsupported browser → explicit "unsupported" screen33+(or opt-in memory mode with a "nothing will persist" banner). Never a silent fallback.34+35+| Feature | iOS Safari tab | iOS installed PWA | Android Chrome 148+ | Framework behavior |36+| ----------------------------------------------- | ----------------------------------------------- | ----------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------ |37+| SharedWorker + Web Locks (Jazz 2.0 persistence) | ✅ 16.4+ (WebKit quirks — Spike 0.5) | ✅ | ✅ (148 restored SharedWorker, adds `extendedLifetime`) | Boot gate |38+| Storage eviction safety | ❌ 7-day ITP | ✅ exempt | ✅ generous | Push install aggressively on iOS tabs ("installing protects your data") |39+| `storage.persist()` | needs notification permission | same | heuristic grant | `requestPersistentStorage()` sequences the permission dance |40+| Install | Share-sheet only; **EU iOS: unavailable (DMA)** | — | `beforeinstallprompt` / WebAPK | iOS coach-mark; Android native prompt; EU degrade banner |41+| Background Sync (all variants) | ❌ never | ❌ never | ✅ | **Unused everywhere** — sync is foreground-only (lowest common denominator, simpler model) |42+| WebSocket while backgrounded | drops | drops | drops/throttled | Reconnect on `visibilitychange`/`pageshow`/`online` |43+| Push | ❌ | ✅ 16.4+ | ✅ | Out of prototype scope; SW is push-ready |44+| WebAuthn passkeys | ✅ iCloud Keychain | ✅ | ✅ | Primary auth |45+| WebAuthn PRF | Safari 18+, no external keys | same | robust | Stretch phase only; feature-detect, never require |46+47+App code never sniffs user agents — `@lofi/pwa` and `@lofi/sync` branch on the `Capabilities` object48+from `detect()`.49+50+## Monorepo layout (Deno workspace)51+52+```53+lofi/54+├── deno.json # workspace: ./packages/*, ./apps/demo; shared tasks55+├── packages/56+│ ├── core/ # @lofi/core — schema (re-export co/z + defineSchema), createApp()57+│ │ # singleton client (lazy, auth-gated), platform.ts detect() boot gate58+│ ├── auth/ # @lofi/auth — registerPasskey/signInWithPasskey/recoverWithPassphrase,59+│ │ # authState signal: "loading" | "anonymous" | "signedIn"60+│ ├── sync/ # @lofi/sync — THE ONLY NETWORK-AWARE MODULE: syncState signal61+│ │ # (connected/syncing/pendingOps/lastSyncedAt), pauseSync/resumeSync,62+│ │ # lifecycle.ts: visibility/online reconnect manager (iOS WS-drop fix)63+│ ├── ui/ # @lofi/ui — Preact hooks: useCoState, useAccount, useAuthState,64+│ │ # useSyncStatus, useOnline; adapter-compat.ts (Plan A) / adapter-vanilla.ts (Plan B)65+│ ├── pwa/ # @lofi/pwa — lofiPwa() @vite-pwa/astro config preset, install-prompt66+│ │ # orchestration (beforeinstallprompt / iOS coach-mark / EU degrade),67+│ │ # requestPersistentStorage() incl. Safari notification-permission dance68+│ └── testing/ # @lofi/testing — Playwright virtual-authenticator + offline/sync test helpers69+└── apps/demo/ # Astro app: "Ticktick-lite" checklist; schema.ts, app.ts, islands/70+```71+72+Dependency direction (strict): `demo → ui,pwa,auth,sync,core` · `ui → sync,auth,core` · `pwa → core`73+· `sync → core` · `auth → core` · `core → npm:jazz-tools` and nothing else. **Only `sync` may74+construct or configure a network peer** — the enforcement point for the "sync is the only network75+surface" constraint.76+77+Multi-tab coordination comes free: the SharedWorker Jazz 2.0 already requires is the single owner of78+storage (and possibly sync) across tabs. Open question for Spike 0: does Jazz 2.0 run the sync79+WebSocket inside the worker or per-tab? That decides where `lifecycle.ts` attaches its reconnect80+manager.81+82+## Public API sketch83+84+```ts85+// schema.ts86+import { co, defineSchema, z } from "@lofi/core";87+export const Task = co.map({ text: z.string(), done: z.boolean(), createdAt: z.date() });88+export const TaskList = co.list(Task);89+export const schema = defineSchema({90+ root: co.map({ lists: co.list(TaskList) }),91+ profile: co.map({ name: z.string() }),92+ migration: (account) => {93+ if (!account.root) account.root = { lists: [] };94+ },95+});96+97+// app.ts — module scope, imported by every island (Vite guarantees one instance per page)98+import { createApp } from "@lofi/core";99+export const app = createApp({100+ schema,101+ sync: { peer: `wss://cloud.jazz.tools/?key=${import.meta.env.PUBLIC_JAZZ_KEY}` },102+ storage: "opfs",103+});104+// createApp() registers config only; the client boots lazily on first hook/auth call,105+// and the sync peer is attached exclusively by @lofi/sync.106+```107+108+```tsx109+// islands/TaskListView.tsx110+import { useCoState } from "@lofi/ui";111+export default function TaskListView({ listId }) {112+ const list = useCoState(TaskList, listId, { resolve: { $each: true } });113+ if (list === undefined) return <Spinner />; // still loading from OPFS114+ if (list === null) return <NotFound />; // unavailable / no permission115+ return (116+ <ul>117+ {list.map((t) => (118+ <li key={t.id}>119+ <input120+ type="checkbox"121+ checked={t.done}122+ onChange={() => {123+ t.done = !t.done;124+ }}125+ />{" "}126+ {t.text} {/* local write; sync invisible */}127+ </li>128+ ))}129+ </ul>130+ );131+}132+133+// The only place networking is even visible:134+const { connected, syncing, pendingOps, lastSyncedAt } = useSyncStatus();135+```136+137+Auth: `await registerPasskey({ name })` · `await signInWithPasskey()` (conditional-UI capable) ·138+`await recoverWithPassphrase(words24)`. Hooks read module-scope signals — no provider component139+exists, which is what makes multi-island apps work.140+141+## The Preact adapter decision (riskiest unknown — resolve first)142+143+- **Plan A (spike):** alias `react`/`react-dom`/`react/jsx-runtime` → `preact/compat` in a bare Vite144+ page; mount jazz-tools/react's provider + `useCoState` + PasskeyAuth. Watch:145+ `useSyncExternalStore` under compat, React-19-only APIs, synthetic-event assumptions.146+- **Plan B (fallback; likely end-state):** hand-rolled hooks over jazz-tools' vanilla147+ `subscribe`/`load` API. `useCoState` ≈ 30 lines (`useState` + `useEffect` around148+ `Schema.subscribe(id, opts, set)`); `useAccount` ≈ 20; sync/auth hooks are just signal readers.149+ **~200–300 LOC total**, no compat alias in app bundles.150+- Decision rule: any flakiness in Plan A → take Plan B immediately. Adopt Plan A only if it's151+ boringly perfect.152+- Either way the public hooks are **context-free** (module-scope client + signals), because context153+ can't span Astro islands. The spike only decides whether we reuse jazz-tools/react's hook154+ internals.155+156+## Risk-ordered roadmap157+158+| # | Phase | Duration | Learn / kill criteria |159+| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |160+| 0 | **Spike: preact/compat × jazz-tools 2.0** — bare Vite+Preact page (no Astro, no Deno), Plan A then Plan B, anonymous account, `useCoState` round-trip to Jazz Cloud; measure gzipped bundle; find where the sync socket lives (worker vs tab) | 2 days | Which adapter plan; bundle cost. **Kill:** both plans fail to subscribe reliably → Jazz choice is dead, re-evaluate data layer |161+| 0.5 | **Spike: OPFS persistence on real devices** — same page with `storage: "opfs"` on a real iPhone (Safari tab + installed) and a real Chrome-148 Android; kill/relaunch, check data survives; multi-tab open | 2 days | Does Jazz 2.0 persistent mode actually work on WebKit's SharedWorker. **Kill:** broken on iOS → fall back to jazz-tools 0.20.x/IndexedDB (architecture unchanged — `storage:` is a named seam) |162+| 1 | **Spike: PasskeyAuth on real devices** — register/sign-in/passphrase-recovery over real HTTPS on iOS Safari tab, installed PWA, Android Chrome, WebAPK; verify the credential survives install | 3 days | Does Jazz's passkey custody work on mobile Safari (its PRF/largeBlob mechanism is undocumented — this is where we find out). **Kill:** fails on iOS → passphrase-primary UX or reconsider |163+| 2 | **Astro shell + islands + Deno toolchain** — scaffold workspace, port spikes into `@lofi/*`, two _separate_ islands sharing one client and live-updating each other (acceptance test for the singleton design); `deno task dev/build/serve` end-to-end with @deno/astro-adapter | 1 wk | Island-boundary state sharing works; npm-compat friction. **Soft kill:** Astro misbehaves under Deno → Node for build, Deno for serve; cap at 1 day |164+| 3 | **PWA hardening** — Workbox shell precache, manifest, Android install prompt, iOS coach-mark, EU-iOS degrade, `storage.persist()` incl. Safari notification-permission dance, reconnect-on-foreground verified on devices | 1 wk | Offline cold-start (airplane mode → installed PWA → shell + data render); WS reconnect reliability. Failures here are degradable UX, not fatal |165+| 4 | **Demo app "Ticktick-lite"** — checklists, shared lists via Group invite links (two accounts, two devices), sync badge, offline edits, conflict-free merge test | 1 wk | Is the API pleasant; where the abstraction leaks; invite-revocation gap surfaces concretely |166+| 5 | **(Stretch) PRF-derived E2E-at-rest** — WebAuthn PRF → HKDF → field-level encryption before data enters Jazz | optional | Only if a real threat model demands it; Safari 18 iCloud-Keychain-only; feature-detect, never require |167+168+## Verification169+170+- **Secure-context device testing:** WebAuthn + SW need HTTPS and a _stable origin_ (rpId changes171+ break passkeys). Preferred: `tailscale serve` (stable `*.ts.net` name); alt: reserved ngrok172+ domain; mkcert for pure LAN (requires installing the root CA profile on iOS). `astro dev` behind173+ the tunnel for iteration; the Deno adapter serves the built shell.174+- **Per-phase device pass:** one-page manual checklist on a physical iPhone (tab + installed) and175+ Android (tab + WebAPK); debug via Safari Web Inspector over USB and `chrome://inspect`.176+- **CI-able WebAuthn:** Playwright CDP virtual authenticator (resident key + user verification) in177+ `@lofi/testing` for register/sign-in/recovery. Real-device passkey behavior still verified178+ manually in Spike 1 (virtual authenticators don't model iCloud Keychain or PRF quirks).179+- **Offline:** Playwright `context.setOffline(true)` for automated shell+data cold-start; manual180+ airplane mode on devices for the installed path.181+- **Sync/merge:** two Playwright contexts against a throwaway Jazz Cloud key: concurrent offline182+ edits → reconnect → assert convergence.183+- **Lifecycle:** manual — background the installed PWA 5+ min on iOS, foreground, assert reconnect +184+ flush via the sync badge.185+186+## Open risks (ranked)187+188+1. **preact/compat × jazz-tools untested** — highest uncertainty, cheapest to resolve (Spike 0);189+ Plan B fallback is fully designed and small.190+2. **Jazz 2.0 OPFS persistence on WebKit** — SharedWorker on iOS Safari exists but is quirk-prone;191+ Spike 0.5 decides. Fallback to 0.20.x/IndexedDB costs nothing architecturally (`storage:` seam).192+3. **Alpha API churn** — pin the exact 2.0-alpha version; expect breakage on every bump;193+ `@lofi/core` is the single jazz-tools chokepoint containing migration cost. 2.0 docs are sparse —194+ read the changelog and source when docs fail.195+4. **Pre-148 Android installed base** — SharedWorker only returned in Chrome 148 (stable ~May–June196+ 2026); older devices hit the boot gate. Acceptable for a prototype; revisit for any real release.197+5. **Astro-under-Deno friction** — pre-agreed fallback: Node for `astro build`, Deno for everything198+ else.199+6. **Jazz bundle size unknown** — measure in Spike 0; `client:only` islands keep it off the shell's200+ critical path.201+7. **iOS lifecycle fragility** — WS drops + eviction + `persist()` permission gating compound; Phase202+ 3 is dedicated to it; the sync badge makes failures visible instead of silent.203+8. **Jazz invites can't be revoked/expired** — leaked link = standing access. Prototype: scope204+ invites to writer role; "revoke" = recreate the list into a fresh Group. Framework-level gap for205+ any real release.206+9. **EU iOS install ban (DMA)** — browser-only mode means eviction exposure with no fix; accepted207+ for the prototype, flagged for distribution.208+209+## Research appendix (mid-2026 findings)210+211+- **Jazz**: v0.20.18 stable / 2.0 alpha. Schema `co.map/co.list/co.feed` + Zod; `FileStream` binary;212+ Groups (reader/writer/admin). Bindings: React, Svelte, Solid (2.0α), vanilla — **no Preact213+ binding**. PasskeyAuth custodies the account secret in a resident credential (PRF/largeBlob use214+ unconfirmed). Sync = WebSocket to Jazz Cloud or self-hosted Node 20+ (Deno Deploy unsupported —215+ moot here). 2.0-alpha OPFS ≈ 5x faster reads/writes; persistent mode requires SharedWorker + Web216+ Locks + MessageChannel, fallback memory-only.217+- **Frontends**: Astro 7.0.7 (static default, `client:only`, @vite-pwa/astro v1.2,218+ `@deno/astro-adapter`); Fresh 2.3.3 (Deno-native, islands, **zero PWA/SW tooling** → rejected);219+ Preact 10 + Vite (SPA only, no prerendered shell → rejected). preact/compat caveats: no React-19220+ `use()`, native events, concurrent rendering no-op.221+- **Platform**: Chrome 148 restored SharedWorker on Android (+ `extendedLifetime`). iOS: installed222+ PWAs exempt from 7-day ITP eviction, tabs are not; no Background Sync of any kind, ever; push only223+ in installed PWAs 16.4+; `storage.persist()` requires notification permission; EU iOS can't224+ install PWAs (DMA). PRF output is deterministic per credential → synced passkeys yield the same225+ derived key across devices.226+- **Niche**: no existing project combines passkey identity + CRDT sync + mobile-PWA-first227+ constraints (Evolu = mnemonic; Automerge = `localfirst/auth` teams; DXOS = HALO keys;228+ Zero/Triplit/PowerSync = JWT/cloud-first; Ditto = device identity). The niche is open.@@ -0,0 +1,21 @@
1+import { validateEnvironment } from "./env_contract.ts";2+import { loadEnvironment } from "./load_env.ts";3+4+const validation = validateEnvironment(await loadEnvironment());5+6+console.log(`lofi environment mode: ${validation.mode}`);7+console.log(8+ `client variables present: ${validation.presentClientNames.join(", ") || "none"}`,9+);10+console.log(11+ `server-only variables present: ${validation.presentServerNames.join(", ") || "none"}`,12+);13+14+for (const warning of validation.warnings) console.warn(`warning: ${warning}`);15+16+if (!validation.ok) {17+ for (const error of validation.errors) {18+ console.error(`error: ${error}`);19+ }20+ Deno.exit(1);21+}@@ -0,0 +1,53 @@
1+import { serverEnvironmentNames } from "./env_contract.ts";2+import { loadNamedEnvironment } from "./load_env.ts";3+import { findSecretLeaks } from "./secret_scan.ts";4+5+async function sourceFiles(): Promise<string[]> {6+ const output = await new Deno.Command("git", {7+ args: ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],8+ stdout: "piped",9+ }).output();10+ if (!output.success) throw new Error("Unable to enumerate source files.");11+ return new TextDecoder().decode(output.stdout).split("\0").filter(Boolean);12+}13+14+async function filesBelow(path: string): Promise<string[]> {15+ try {16+ const result: string[] = [];17+ for await (const entry of Deno.readDir(path)) {18+ const child = `${path}/${entry.name}`;19+ if (entry.isDirectory) result.push(...await filesBelow(child));20+ else if (entry.isFile) result.push(child);21+ }22+ return result;23+ } catch (error) {24+ if (error instanceof Deno.errors.NotFound) return [];25+ throw error;26+ }27+}28+29+const environment = await loadNamedEnvironment(serverEnvironmentNames);30+const secretValues = serverEnvironmentNames31+ .map((name) => ({ name, value: environment[name]?.trim() ?? "" }))32+ .filter(({ value }) => value.length > 0);33+const candidates = [...new Set([...await sourceFiles(), ...await filesBelow("dist")])];34+const files: Array<{ path: string; content: Uint8Array }> = [];35+36+for (const path of candidates) {37+ try {38+ files.push({ path, content: await Deno.readFile(path) });39+ } catch (error) {40+ if (error instanceof Deno.errors.NotFound) continue;41+ throw error;42+ }43+}44+45+const leaks = findSecretLeaks(files, secretValues);46+if (leaks.length > 0) {47+ for (const leak of leaks) console.error(`error: ${leak.name} value found in ${leak.path}`);48+ Deno.exit(1);49+}50+51+console.log(52+ `secret leak check passed (${candidates.length} files, ${secretValues.length} available server secrets)`,53+);@@ -0,0 +1,92 @@
1+export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;2+export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3+export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;4+5+interface EnvironmentResultBase {6+ presentClientNames: string[];7+ presentServerNames: string[];8+ warnings: string[];9+}10+11+export type EnvironmentValidation =12+ & EnvironmentResultBase13+ & (14+ | {15+ ok: true;16+ mode: "local-only";17+ client: Record<string, never>;18+ }19+ | {20+ ok: true;21+ mode: "cloud-configured";22+ client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };23+ }24+ | {25+ ok: false;26+ mode: "invalid";27+ errors: string[];28+ }29+ );30+31+function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {32+ return (env[name] ?? "").trim().length > 0;33+}34+35+export function validateEnvironment(36+ env: Readonly<Record<string, string>>,37+): EnvironmentValidation {38+ const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));39+ const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));40+ const errors: string[] = [];41+ const warnings: string[] = [];42+43+ if (44+ presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length45+ ) {46+ const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));47+ errors.push(48+ `Cloud sync cannot start because configuration is incomplete; set ${49+ missing.join(", ")50+ } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,51+ );52+ }53+54+ if (isPresent(env, "JAZZ_SERVER_URL")) {55+ try {56+ const url = new URL(env.JAZZ_SERVER_URL);57+ if (!new Set(["http:", "https:"]).has(url.protocol)) {58+ errors.push(59+ "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",60+ );61+ }62+ } catch {63+ errors.push(64+ "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",65+ );66+ }67+ }68+69+ const base: EnvironmentResultBase = {70+ presentClientNames: [...presentClientNames],71+ presentServerNames: [...presentServerNames],72+ warnings,73+ };74+ if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };75+ if (presentClientNames.length === 0) {76+ if (presentServerNames.length > 0) {77+ warnings.push(78+ "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",79+ );80+ }81+ return { ...base, ok: true, mode: "local-only", client: {} };82+ }83+ return {84+ ...base,85+ ok: true,86+ mode: "cloud-configured",87+ client: {88+ JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),89+ JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),90+ },91+ };92+}@@ -0,0 +1,76 @@
1+import { validateEnvironment } from "./env_contract.ts";2+import { loadEnvironment, mergeEnvironment, parseDotenv } from "./load_env.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+Deno.test("empty environment selects local-only mode", () => {9+ const result = validateEnvironment({});10+ assert(result.ok, "empty local mode should be valid");11+ assert(result.mode === "local-only", "expected local-only mode");12+});13+14+Deno.test("cloud configuration requires the complete public pair", () => {15+ const result = validateEnvironment({ JAZZ_APP_ID: "app" });16+ assert(!result.ok, "incomplete cloud configuration should be invalid");17+ assert(result.mode === "invalid", "invalid config must not be cloud-configured");18+ assert(result.errors.length === 1, "incomplete cloud configuration should fail");19+ assert(result.errors[0].includes("JAZZ_SERVER_URL"), "error should name the missing variable");20+});21+22+Deno.test("cloud configuration validates its URL without printing values", () => {23+ const result = validateEnvironment({24+ JAZZ_APP_ID: "app",25+ JAZZ_SERVER_URL: "not-a-url",26+ });27+ assert(!result.ok, "invalid URL should produce an invalid result");28+ assert(result.errors.length === 1, "invalid URL should fail");29+ assert(!result.errors[0].includes("not-a-url"), "error must not echo configuration values");30+});31+32+Deno.test("validated cloud result projects only the complete public pair", () => {33+ const result = validateEnvironment({34+ JAZZ_APP_ID: "app",35+ JAZZ_SERVER_URL: "https://example.test",36+ JAZZ_ADMIN_SECRET: "admin-secret",37+ BACKEND_SECRET: "backend-secret",38+ });39+ assert(result.ok && result.mode === "cloud-configured", "cloud pair should validate");40+ assert(result.client.JAZZ_APP_ID === "app", "public app ID should be projected");41+ assert(!("JAZZ_ADMIN_SECRET" in result.client), "admin secret must not be projected");42+ assert(!("BACKEND_SECRET" in result.client), "backend secret must not be projected");43+});44+45+Deno.test("dotenv parsing is allowlisted and process values win", () => {46+ const parsed = parseDotenv(`47+ JAZZ_APP_ID=file-app48+ JAZZ_SERVER_URL="https://file.example"49+ UNRELATED_SECRET=ignored50+ `);51+ const merged = mergeEnvironment(52+ parsed,53+ (name) => name === "JAZZ_APP_ID" ? "process-app" : undefined,54+ );55+ assert(merged.JAZZ_APP_ID === "process-app", "process environment must take precedence");56+ assert(57+ merged.JAZZ_SERVER_URL === "https://file.example",58+ "file value should fill missing process value",59+ );60+ assert(!("UNRELATED_SECRET" in merged), "unrelated variables must never enter the projection");61+});62+63+Deno.test("optional env file changes mode without exposing values", async () => {64+ const path = await Deno.makeTempFile({ dir: ".", prefix: ".env.contract-test-" });65+ try {66+ await Deno.writeTextFile(67+ path,68+ "JAZZ_APP_ID=file-app\nJAZZ_SERVER_URL=https://file.example\n",69+ );70+ const loaded = await loadEnvironment(path, () => undefined);71+ const result = validateEnvironment(loaded);72+ assert(result.ok && result.mode === "cloud-configured", "env file should configure cloud mode");73+ } finally {74+ await Deno.remove(path);75+ }76+});@@ -0,0 +1,62 @@
1+import { environmentNames } from "./env_contract.ts";2+3+export function parseDotenv(source: string): Record<string, string> {4+ const parsed: Record<string, string> = {};5+ for (const originalLine of source.split(/\r?\n/)) {6+ let line = originalLine.trim();7+ if (line.length === 0 || line.startsWith("#")) continue;8+ if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();9+ const separator = line.indexOf("=");10+ if (separator < 1) continue;11+ const name = line.slice(0, separator).trim();12+ if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;13+ let value = line.slice(separator + 1).trim();14+ if (15+ value.length >= 2 &&16+ ((value.startsWith('"') && value.endsWith('"')) ||17+ (value.startsWith("'") && value.endsWith("'")))18+ ) {19+ value = value.slice(1, -1);20+ }21+ parsed[name] = value;22+ }23+ return parsed;24+}25+26+export function mergeEnvironment(27+ fileEnvironment: Readonly<Record<string, string>>,28+ processLookup: (name: string) => string | undefined,29+): Record<string, string> {30+ return mergeNamedEnvironment(environmentNames, fileEnvironment, processLookup);31+}32+33+export function mergeNamedEnvironment(34+ names: readonly string[],35+ fileEnvironment: Readonly<Record<string, string>>,36+ processLookup: (name: string) => string | undefined,37+): Record<string, string> {38+ return Object.fromEntries(39+ names.map((name) => [name, processLookup(name) ?? fileEnvironment[name] ?? ""]),40+ );41+}42+43+export async function loadEnvironment(44+ path = ".env",45+ processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),46+): Promise<Record<string, string>> {47+ return await loadNamedEnvironment(environmentNames, path, processLookup);48+}49+50+export async function loadNamedEnvironment(51+ names: readonly string[],52+ path = ".env",53+ processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),54+): Promise<Record<string, string>> {55+ let fileEnvironment: Record<string, string> = {};56+ try {57+ fileEnvironment = parseDotenv(await Deno.readTextFile(path));58+ } catch (error) {59+ if (!(error instanceof Deno.errors.NotFound)) throw error;60+ }61+ return mergeNamedEnvironment(names, fileEnvironment, processLookup);62+}@@ -0,0 +1,29 @@
1+function assert(condition: unknown, message: string): asserts condition {2+ if (!condition) throw new Error(message);3+}4+5+Deno.test("real environment files are ignored and untracked", async () => {6+ const ignored = await new Deno.Command("git", {7+ args: ["check-ignore", ".env"],8+ stdout: "null",9+ stderr: "piped",10+ }).output();11+ assert(ignored.success, ".env must be ignored");12+13+ const tracked = await new Deno.Command("git", {14+ args: ["ls-files", ".env", ".env.*"],15+ stdout: "piped",16+ }).output();17+ const trackedFiles = new TextDecoder().decode(tracked.stdout).trim().split("\n").filter(Boolean);18+ assert(19+ trackedFiles.every((path) => path === ".env.example"),20+ `only .env.example may be tracked, found: ${trackedFiles.join(", ")}`,21+ );22+});23+24+Deno.test("environment example contains names but no values", async () => {25+ const example = await Deno.readTextFile(".env.example");26+ const assignments = example.split("\n").filter((line) => /^[A-Z][A-Z0-9_]*=/.test(line));27+ assert(assignments.length === 4, "expected four documented environment variables");28+ assert(assignments.every((line) => line.endsWith("=")), ".env.example must not contain values");29+});@@ -0,0 +1,33 @@
1+export interface SecretValue {2+ name: string;3+ value: string;4+}5+6+export interface SecretLeak {7+ name: string;8+ path: string;9+}10+11+function contains(haystack: Uint8Array, needle: Uint8Array): boolean {12+ if (needle.length === 0 || haystack.length < needle.length) return false;13+ outer:14+ for (let offset = 0; offset <= haystack.length - needle.length; offset++) {15+ for (let index = 0; index < needle.length; index++) {16+ if (haystack[offset + index] !== needle[index]) continue outer;17+ }18+ return true;19+ }20+ return false;21+}22+23+export function findSecretLeaks(24+ files: ReadonlyArray<{ path: string; content: Uint8Array }>,25+ secrets: readonly SecretValue[],26+): SecretLeak[] {27+ const encoder = new TextEncoder();28+ return files.flatMap(({ path, content }) =>29+ secrets30+ .filter(({ value }) => contains(content, encoder.encode(value)))31+ .map(({ name }) => ({ name, path }))32+ );33+}@@ -0,0 +1,36 @@
1+import { serverEnvironmentNames } from "./env_contract.ts";2+import { loadNamedEnvironment } from "./load_env.ts";3+import { findSecretLeaks } from "./secret_scan.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+Deno.test("server secret loaded only from env file is detected in a build fixture", async () => {10+ const directory = await Deno.makeTempDir({ dir: ".", prefix: ".secret-scan-test-" });11+ const envPath = `${directory}/.env`;12+ const artifactPath = `${directory}/dist/app.js`;13+ const secret = "fixture-server-secret-that-must-not-leak";14+ try {15+ await Deno.mkdir(`${directory}/dist`);16+ await Deno.writeTextFile(envPath, `JAZZ_ADMIN_SECRET=${secret}\n`);17+ await Deno.writeTextFile(artifactPath, `globalThis.accidental = "${secret}";\n`);18+ const environment = await loadNamedEnvironment(19+ serverEnvironmentNames,20+ envPath,21+ () => undefined,22+ );23+ const leaks = findSecretLeaks(24+ [{ path: artifactPath, content: await Deno.readFile(artifactPath) }],25+ serverEnvironmentNames26+ .map((name) => ({ name, value: environment[name] ?? "" }))27+ .filter(({ value }) => value.length > 0),28+ );29+ assert(leaks.length === 1, "expected one detected leak");30+ assert(leaks[0].name === "JAZZ_ADMIN_SECRET", "leak should name the source variable");31+ assert(leaks[0].path === artifactPath, "leak should name the artifact path");32+ assert(!("value" in leaks[0]), "leak result must not expose the secret value");33+ } finally {34+ await Deno.remove(directory, { recursive: true });35+ }36+});