# lofi documentation (v0.4.0) > A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, and private, direct-share, or group access templates. Deployed at https://lofi.host. Package: https://jsr.io/@nzip/lofi --- # lofi developer documentation These guides are for developers building an application from the `@nzip/lofi` generated template. They describe the checked-out package version and its generated project—not the older prototype sketches under `docs/spikes/`. ## Start here Work through the guides in order; each builds on the project state the previous one leaves behind. | Step | Guide | What you do | | ---- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | 1 | [Getting started](getting-started.md) | Scaffold an app, verify local persistence, and identify the files you are expected to change. | | 2 | [Data and UI](data-and-ui.md) | Connect a Jazz table to a typed Preact hook and island. | | 3 | [Permissions](permissions.md) | Understand and change the starter's owner-only access policy. | | 4 | [Sync and recovery](sync-and-recovery.md) | Provision managed sync and understand the account lifecycle. | | 5 | [Testing](testing.md) | Run fast tests and the opt-in two-client offline convergence example. | | 6 | [Deployment](deployment.md) | Build, preview, customize, and host the static PWA. | | 7 | [Application-origin migration](application-origin-migration.md) | Move a deployed app across a browser security boundary with recovery, verification, rollback, and retirement. | Blocked at any step — environment, browser, build, or test failures? Go straight to [Troubleshooting](troubleshooting.md). The root [README](https://github.com/FelineStateMachine/lofi/blob/main/README.md) provides the shortest product overview and command summary. AI agents can ingest these docs as [llms.txt](https://lofi.host/llms.txt) (index) or [llms-full.txt](https://lofi.host/llms-full.txt) (complete corpus including the API reference). ## Reference - [Commands](reference/commands.md) - [Configuration](reference/configuration.md) - [Generated project layout](reference/project-layout.md) - [Identity and recovery model](auth-identity.md) - [Account and access API](reference/access.md) - [Shared example](examples/shared.md) - [Group example](examples/group.md) - [Collaborative list recipe](examples/collaborative-list.md) - [Optional installed-app recipes](recipes/README.md) - [Advanced device-auth primitive](auth.md) ## The author boundary Generated projects intentionally divide application source from versioned framework code: ```mermaid flowchart TB subgraph Author["You own"] Schema["schema.ts"] Permissions["permissions.ts"] App["app.ts"] UI["pages, layouts, islands, styles"] Public["public assets"] Tests["tests"] end subgraph Framework["lofi owns"] Runtime["@nzip/lofi package"] Storage["storage and subscriptions"] Identity["identity and recovery"] Lifecycle["sync, PWA, lifecycle"] end UI --> Runtime Runtime --> Storage Runtime --> Identity Runtime --> Lifecycle ``` - Change `src/schema.ts`, `src/permissions.ts`, `src/app.ts`, `src/pages/`, `src/layouts/`, `src/islands/`, and `src/styles/`. - Import documented runtime seams from `@nzip/lofi`; no framework implementation is copied into `src/`. - Change files under `public/` when customizing the icon or manifest. The package build generates the service worker. - Keep application tests under `tests/`. ## Framework contributor material The following documents explain why lofi behaves as it does. They are useful when changing the framework, but they are not application tutorials: - [Developer-experience contract](https://github.com/FelineStateMachine/lofi/blob/main/docs/devx-contract.md) - [Historical prototype seed](https://github.com/FelineStateMachine/lofi/blob/main/docs/seed.md) - [`docs/spikes/`](https://github.com/FelineStateMachine/lofi/tree/main/docs/spikes) and retained evidence Spike evidence records the state of an experiment at a specific commit and package pin. Preserve it as historical evidence; do not update it to look like the current generated app. --- # Getting started with a generated lofi app This guide begins after installing [Deno 2.9 or newer](https://docs.deno.com/runtime/). ## Create and run the app ```sh deno run -A jsr:@nzip/lofi/create my-app cd my-app deno task dev ``` Open the URL printed by Astro. The starter contains a task list, an account panel when sync is available, and device/runtime diagnostics. Verify the local-first baseline before changing anything: ```mermaid flowchart LR A["Add a task"] --> B["Reload"] B --> C{"Task remains?"} C -- "yes" --> D["Disconnect network"] D --> E["Add or complete a task"] E --> F{"UI still works?"} C -- "no" --> G["Run doctor"] F -- "no" --> G F -- "yes" --> H["Local-first baseline verified"] ``` The default app is local-only. It does not require an `.env`, an account ceremony, or a backend. ## Run the project checks ```sh deno task doctor deno task test deno task build deno task preview ``` `doctor` checks the project layout, configuration, manifest, and referenced install assets without printing values. `test` runs the fast, deterministic suite. `build` creates the production PWA in `dist/`, then verifies its base-aware HTML links, manifest, worker, build identity, and precache as one artifact. `preview` serves that exact output at `http://127.0.0.1:4321/` by default. ## Know which files to change | File or directory | Purpose | | -------------------- | ----------------------------------------------------------- | | `src/schema.ts` | Tables and typed fields | | `src/permissions.ts` | Read and mutation policies | | `src/app.ts` | App name, database name, stable origins, and repository URL | | `src/pages/` | Astro pages and shell composition | | `src/islands/` | Preact hooks and interactive UI | | `src/styles/` | Application styling | | `public/` | Manifest, icon, and other product assets | | `tests/` | Application and local-first browser tests | Application hooks import supported runtime seams from `@nzip/lofi`, as the generated `use-tasks.ts` does. The boundary keeps Jazz networking and storage machinery out of product components while allowing framework fixes to arrive through a package upgrade. The final package map is intentionally small: | Import | Supported use | | -------------------------------------------- | --------------------------------------------------- | | `@nzip/lofi` | App configuration, runtime, session, stores, PWA | | `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers | | `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI | | `@nzip/lofi/astro` | Package-owned Astro/Vite integration | | `@nzip/lofi/recipes/file-handler` | Optional validated installed-app file import drafts | | `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing | | `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links | | `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery | | `@nzip/lofi/recipes/scope-extension` | Optional reciprocal cross-origin app-window scope | | `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing | | `@nzip/lofi/recipes/window-controls-overlay` | Optional desktop titlebar geometry observation | | `@nzip/lofi/testing` | Local-first Playwright helpers | | `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints | All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to receive framework fixes; do not copy package files into `src/` or import unpublished internal paths. Direct vendor access is an unsupported escape hatch: isolate it from ordinary product files and do not expect lofi compatibility guarantees for it. ## Customize the application identity first Before accumulating local data, update `src/app.ts`: - `name` is the human-facing application name. - `databaseName` namespaces durable browser storage. - `repositoryUrl` controls the starter footer link. - `credentialOrigins` lists hostnames you intend to keep stable if you use device credentials. Also update `public/manifest.webmanifest`, `public/favicon.svg`, the page title, and starter copy. Changing `databaseName` later points the browser at a different local database. Choose it early so a cosmetic rename does not make existing device data appear to disappear. ## Replace the task example The fastest path is to change one layer at a time while keeping the app runnable: ```mermaid flowchart LR A["Edit schema"] --> B["Update permissions"] B --> C["Create domain hook"] C --> D["Build the island UI"] D --> E["Update tests"] E --> F["doctor → test → build"] ``` Continue with [Data and UI](data-and-ui.md) for the table-to-island pattern and [Permissions](permissions.md) before exposing new data. ## Add sync only when you need it Local-only development is the normal starting point. To add managed Jazz sync and account recovery: ```sh deno task jazz:provision ``` Then follow [Sync and recovery](sync-and-recovery.md). Provisioning makes sync available; each user still chooses whether their existing local account should begin syncing. --- # Data and UI Lofi keeps realtime plumbing out of product components. Authors declare Jazz tables and policies, build exact typed queries, and compose two package hooks inside a domain hook: ```mermaid flowchart LR Schema["Typed Jazz schema"] --> Policy["Private, Shared, or Group policy"] Schema --> Query["Typed scoped query"] Query --> Live["useLiveQuery"] Table["Typed table"] --> Mutations["useTableMutations"] Live --> Domain["Author-owned domain hook"] Mutations --> Domain Domain --> UI["Preact product UI"] ``` The root package owns shared query stores, runtime recreation, subscription teardown, mutation errors, and durability tracking. `@nzip/lofi/preact` owns only the Preact lifecycle adapter. ## Declare a table and policy ```ts import { schema as s } from "jazz-tools"; const schema = { records: s.table({ workspaceId: s.string(), title: s.string(), archived: s.boolean(), createdAt: s.timestamp(), }), }; export const app = s.defineApp(schema); ``` `src/schema.ts` and `src/permissions.ts` are the two deliberate raw-Jazz surfaces in author source. UI islands stay on public package seams: derive row types with `RowOf` from `@nzip/lofi` (`type Record = RowOf`) instead of importing the vendor module, and the generated author-boundary test enforces exactly that split. Every table needs a policy in `src/permissions.ts`. Permissions determine which rows enter a live query and which mutations succeed; realtime access is not a separate permission mode. See [Permissions](permissions.md), [direct sharing](examples/shared.md), and [group ownership](examples/group.md). ## Bind an exact typed query ```ts import { useLiveQuery } from "@nzip/lofi/preact"; import { app } from "../app.ts"; const records = useLiveQuery( () => app.schema.records .where({ workspaceId, archived: false }) .orderBy("createdAt", "desc"), [workspaceId], ); ``` The result preserves the row produced by the exact builder, including `select` and `include` projections: ```ts const titles = useLiveQuery( () => app.schema.records.select("title").where({ workspaceId }), [workspaceId], ); // titles.rows contains id and title, not the unselected application columns. ``` Equivalent mounted queries share one Jazz subscription. When dependencies change, Lofi releases the obsolete query before opening the replacement. The last consumer evicts the store. Runtime or account recreation reconnects mounted queries and ignores callbacks from an obsolete client. Read state is deliberately small: | Field | Values | Meaning | | -------- | --------------------------- | ---------------------------------------- | | `status` | `loading`, `ready`, `error` | Subscription/read state | | `rows` | Exact typed query rows | Current authorized result | | `error` | Message or `null` | Query setup or runtime acquisition error | An empty array with `status: "ready"` is an empty result, not a loading signal. ## Mutate the underlying table ```ts import { useTableMutations } from "@nzip/lofi/preact"; const records = useTableMutations(app.schema.records); const created = await records.insert({ workspaceId, title: "Release notes", archived: false, createdAt: new Date(), }); await records.update(created.id, { archived: true }); await records.remove(created.id); ``` `insert` returns the created row, including its generated `id`. Mutation promises resolve only after local durability. When managed sync is active, global confirmation continues in the background and updates the shared table-scoped state: | Field | Values | Meaning | | ------------ | ----------------------------------- | ---------------------------------- | | `pending` | Number | Local durability waits in progress | | `durability` | `none`, `local`, `global`, `failed` | Latest mutation outcome | | `error` | Message or `null` | Awaited or asynchronous rejection | Several filtered queries can observe the same table mutation without owning mutation listeners. All `useTableMutations` consumers for one schema table share one listener and one state surface. ## Keep product UI domain-shaped ```ts export function useWorkspaceRecords(workspaceId: string) { const query = useLiveQuery( () => app.schema.records.where({ workspaceId, archived: false }), [workspaceId], ); const mutations = useTableMutations(app.schema.records); return { ...query, durability: mutations.durability, createRecord: (title: string) => mutations.insert({ workspaceId, title, archived: false, createdAt: new Date(), }), archiveRecord: (id: string) => mutations.update(id, { archived: true }), }; } ``` Components consume `createRecord` and `archiveRecord`; they do not call `getRuntime`, manage Jazz subscriptions, or listen for runtime recreation. The generated task hook is the smallest working example. For a complete access-aware composition, see the [collaborative list recipe](examples/collaborative-list.md). ## Schema changes and migrations Use `deno task schema:validate` while editing. For existing data, create and review a migration with `deno task migrations:create`, then use `migrations:push` and `schema:deploy` only with the intended managed configuration. Never put server-only secrets in source or browser code. --- # Permissions `src/permissions.ts` is an author-owned security boundary. It defines which signed local-first identity may read or mutate each table; hiding a button in the UI is not a substitute for policy. ## The starter policy The generated task app uses creator-owned rows: ```ts import { schema as s } from "jazz-tools"; import { app } from "./schema.ts"; export default s.definePermissions(app, ({ policy, session }) => { policy.tasks.allowInsert.always(); policy.tasks.allowRead.where({ $createdBy: session.user_id }); policy.tasks.allowUpdate.where({ $createdBy: session.user_id }); policy.tasks.allowDelete.where({ $createdBy: session.user_id }); }); ``` This means: - Any valid app session may create a task. - A task is readable only by the identity that created it. - Only that creator may update or delete it. - Recovering the same identity restores the same authority; opening a new local account does not. ## Adding a table Add all four decisions deliberately for each new table: 1. Who may insert? 2. Who may read a row? 3. Who may update it? 4. Who may delete it? Start with creator ownership unless the product has a concrete collaboration model. Do not make a table broadly readable merely to get a UI prototype working. ## Changing an existing policy Treat a permission change like a data migration: 1. Describe the intended actors and operations in plain language. 2. Update `src/permissions.ts`. 3. Run `deno task schema:validate`. 4. Run the application tests with at least one allowed and one rejected identity case. 5. Deploy the schema/permission bundle explicitly with `deno task schema:deploy`. Managed sync configuration is required to publish schema and permission changes. Local-only mode is still useful for UI work, but it does not prove a cloud permission policy was deployed correctly. ## Choose one narrow access template The starter remains private by default. When a resource has a concrete collaboration model, `@nzip/lofi/access` compiles three narrow templates through Jazz's own policy builder: | Template | Who can read | Who can mutate | Sync | | --------------- | -------------------------------- | --------------------------------------------------- | -------- | | `privateAccess` | Row creator | Row creator | Optional | | `sharedAccess` | Creator plus explicit recipients | Creator; recipients with editable grants may update | Required | | `groupAccess` | Group members | Determined by the fixed group role | Required | The schema remains raw Jazz: ```ts import { schema as s } from "jazz-tools"; import { groupMembershipTable, sharedGrantTable } from "@nzip/lofi/access"; const schema = { notes: s.table({ title: s.string() }), noteGrants: sharedGrantTable("notes"), workspaces: s.table({ name: s.string() }), workspaceMembers: groupMembershipTable("workspaces"), documents: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }), }; export const app = s.defineApp(schema); ``` Then compose policies: ```ts export default defineAccessPolicies(app, [ sharedAccess({ resource: app.notes, grants: app.noteGrants }), groupAccess({ groups: app.workspaces, members: app.workspaceMembers, resources: app.documents, groupId: "workspaceId", }), ]); ``` This is not a general schema facade. Use `s.table()`, `s.defineApp()`, and `s.definePermissions()` directly whenever the templates do not fit. `defineAccessPolicies` also accepts a final raw-policy callback for a small extension without hiding Jazz. ## Fixed group roles | Role | Read | Create | Edit own | Edit any | Manage members | | ------------- | ---- | ------ | -------- | -------- | -------------- | | `reader` | yes | no | no | no | no | | `contributor` | yes | yes | yes | no | no | | `writer` | yes | yes | yes | yes | no | | `admin` | yes | yes | yes | yes | yes | The helper stores the role label plus fixed capability bits because the pinned Jazz alpha rejects role-string comparisons inside relationship policies. Only the four role names are accepted by the typed operations; custom roles belong in a raw Jazz policy. **The group creator holds a permanent superseat.** Whoever created the group row can always update it and — because membership management derives from group-update authority — can always restore their own admin membership, even after other admins demote or remove them. This is a documented property of the template, not revocable admin membership: the pinned Jazz alpha cannot express "creator authority only until the first admin exists" (its policy engine drops negated existence conditions), so do not present creator handover as complete in product UI. The creator can also delete their own group rows, which is what lets group creation roll back cleanly if bootstrapping the first admin membership fails. See [the decision record](decisions/group-creator-authority-alpha53.md). Direct shares and group membership use a non-secret `lofi1::` sharing identity. It is safe to copy for this purpose but is not a directory, invitation, or sign-in token. The package deliberately does not discover users or deliver invitations. See the focused [Shared](examples/shared.md) and [Group](examples/group.md) examples and the [access API reference](reference/access.md). When adding collaboration, test at least: - the creator's access; - an explicitly authorized second identity; - an unrelated identity; - access after the product's revoke/remove action; - offline edits followed by reconnection. --- # Sync and recovery Sync is optional infrastructure layered onto an account that already works locally. Configuring a Jazz app makes sync available; it does not automatically upload every user's local data. Each user elects to back up and sync from the generated `AccountGate` UI. ## Provision a managed Jazz app From an existing generated project: ```sh deno task jazz:provision ``` Or provision during project creation: ```sh deno run -A jsr:@nzip/lofi/create --sync my-app ``` Provisioning writes four names to the git-ignored `.env`: | Name | Exposure | Purpose | | ------------------- | -------------- | --------------------------------------- | | `JAZZ_APP_ID` | Client-visible | Identifies the managed Jazz application | | `JAZZ_SERVER_URL` | Client-visible | Selects the managed sync endpoint | | `JAZZ_ADMIN_SECRET` | Server-only | Authorizes schema/permission operations | | `BACKEND_SECRET` | Server-only | Reserved server-side credential | Claim the generated Jazz application within the window printed by the command. Keep `.env` private; the build projects only the complete public pair and scans the client output for secret values. Run `deno task doctor` after provisioning. A partial public pair is invalid: set both public names or remove both to return to local-only mode. ## The user's account journey ```mermaid stateDiagram-v2 [*] --> LocalOnly: First boot state "Local-only
Private on-device account
Durable local data" as LocalOnly LocalOnly --> Syncing: User creates recoverable passkey and saves phrase state "Syncing
Same account identity
Local and global durability" as Syncing Syncing --> LocalOnly: Stop syncing Syncing --> Recovered: Use passkey or enter phrase state "Recovered
Same identity restored
Synced data downloads" as Recovered Recovered --> Syncing ``` Data created before opt-in carries forward because enabling sync does not replace the account secret. ## Recovery guarantees and limits - The recoverable passkey stores the same account secret inside a resident, user-verifying credential. Restoring it replaces the Jazz client and confirms the same `session.user_id`. - Passkey availability is provider- and RP-ID-dependent. Pin `passkey.rpId` to the canonical production hostname. A passkey created for a preview hostname cannot move to another RP-ID. - iCloud Keychain, Google Password Manager, and third-party managers have different platform boundaries. lofi does not promise universal iOS/Android/browser/provider portability. - The recovery phrase is the account authority. Anyone with all the words can act as that account. - lofi does not retain material that can reconstruct the account for the user. - Losing both the device and every copy of the phrase loses the account. - Recovering a phrase can retrieve data that was synced. It cannot reconstruct writes that existed only in storage on a lost device. - Restoring replaces the current device account, so the generated UI confirms before abandoning an unsynced local identity. - Older lofi guard-only credentials protect phrase reveal on one device; they do not contain the Jazz secret and are not recoverable account backups. Read [Identity and recovery model](auth-identity.md) for the detailed state machine and custody model. ## Before shipping sync - Customize the account copy without weakening the recovery warnings. - Confirm the production build receives the intended public configuration. - Run `deno task build` and verify the secret scan passes. - Test recovery using throwaway data and a second browser profile or device. - Run the two-client convergence example described in [Testing](testing.md). - Decide how users will store their phrase safely; there is no server-assisted reset flow. --- # Testing a lofi app The generated template separates fast deterministic tests from opt-in browser scenarios that need a running app, Chromium, and—for convergence—managed sync. Framework release checks also exercise recoverable accounts and access policies; generated applications reuse those package-owned seams. ```mermaid flowchart TB Change["Application change"] --> Fast["deno task test
deterministic suite"] Fast --> Build["build + preview
production shell"] Build --> Manual["reload and offline checks"] Build --> Browser["two-client convergence
opt-in Playwright"] Manual --> Device["physical iOS and Android"] Browser --> Device Device --> Ship["Ready to ship"] ``` ## Run the default suite ```sh deno task test ``` The default suite covers application tests without launching a browser. Framework runtime contracts run once in the `@nzip/lofi` package suite instead of being copied into every application. Keep domain logic and permission-shape checks in this fast path when possible. ## Test the production build manually ```sh deno task build deno task preview ``` Then verify: 1. Add data and reload. 2. Confirm the status reports local durability. 3. Disable the network and continue reading and writing. 4. Reload while offline to exercise the production service worker. 5. Restore the network and confirm the local data remains. The service worker is intentionally disabled during `deno task dev`; use a production build when testing offline shell startup. ## Run the two-client convergence example First configure managed sync and start the app: ```sh deno task jazz:provision deno task dev ``` In another terminal, point the included browser example at the printed URL: ```sh LOFI_E2E_BASE_URL=http://127.0.0.1:4321/ \ deno test -A tests/convergence_e2e_test.ts ``` ```mermaid sequenceDiagram participant A as Client A participant B as Client B participant S as Managed sync A->>A: Go offline and edit B->>B: Go offline and edit A->>S: Reconnect and upload B->>S: Reconnect and upload S-->>A: Both edits S-->>B: Both edits A->>A: Assert convergence B->>B: Assert convergence ``` Both browser contexts use one test identity. Without `LOFI_E2E_BASE_URL`, the example skips so the default suite remains fast. Two more opt-in browser gates ship with the starter: - `tests/backup_migration_e2e_test.ts` — the account journey in one real browser: rows written local-only must survive electing backup and sync (the runtime copies them into the managed namespace during the reload, settling at local durability, so the sync server does not need to be reachable), and the phrase-reveal guard must accept only its enrolled passkey — after the authenticator loses its credentials, the reveal fails closed. Serve on `localhost` (a passkey RP-ID cannot bind to a bare IP). - `tests/auth_e2e_test.ts` — the device-credential enroll → authenticate round-trip on a stable origin listed in `credentialOrigins`. If Chromium is missing: ```sh deno run -A npm:playwright@1.61.1 install chromium ``` ## Adapt the example to your UI Update these four pieces in `tests/convergence_e2e_test.ts`: - `ready` — a DOM condition proving the local store has hydrated; - `apply` — the user action that makes one local mutation; - `locallyApplied` — proof that the offline client rendered its own mutation; - `converged` — proof that both clients eventually render all expected results. Use stable accessible roles, labels, and application-owned `data-*` attributes. Avoid arbitrary sleeps; readiness helpers retry observable conditions until their timeout. ## Failure artifacts Browser fixtures can save artifacts under `test-results/`. Snapshot callbacks should contain counts, booleans, state names, or sanitized identifiers—not task text, environment values, recovery phrases, or other user data. ## Framework recovery and permission evidence The framework repository's `deno task check` includes the access-security suite. It uses Jazz's permission-test app for owner, recipient, unrelated-user, revoke, fixed-role, membership, removal, and self-leave rejection cases, plus a real local Jazz server for offline grant/revoke and membership reconciliation. `deno task test:golden` extends the existing generated-app runner with a two-profile journey. A Chromium virtual authenticator creates and exports a resident credential, a fresh browser process imports it, and the real WebAuthn assertion restores the same Jazz principal and synced rows. The runner pins the exported credential as the assertion's allow-list because headless Chromium cannot show its resident-credential account chooser. It also proves recovery-phrase fallback when the credentials API is unavailable and proves that stopped sync retains the managed local replica through a production offline reload. This is automated virtual-authenticator evidence, not physical iOS or Android evidence. It does not prove passkey-provider portability, mobile installed-PWA lifecycle behavior, or transport convergence without the local Jazz server used by the journey. ## Physical-device checks Browser automation does not prove installed-PWA storage and lifecycle behavior on iOS or Android. Before shipping, use a stable HTTPS origin and exercise installation, termination, device restart, offline cold start, foreground recovery, and account recovery on every supported mobile surface. The WebAuthn PRF extension is the one credential path no virtual authenticator models, so PRF is feature-detected in the runtime and must be validated on real hardware. The manual pass, per supported platform/passkey-provider pair: 1. `getAuthCapability()` reports `prf: "available"` (or `"not-reported"` with a working derive). 2. Enroll a device credential on the pinned production RP-ID and derive a PRF secret twice with the same salt — both ceremonies must yield the same 32 bytes. 3. Encrypt with the derived at-rest key, restart the browser/app, derive again, and decrypt. 4. Confirm a different salt yields a different secret and cannot decrypt the first blob. 5. On providers that sync passkeys, repeat the derive on a second device of the same ecosystem and record whether PRF results roam — do not assume they do. --- # Building and deploying a lofi app lofi produces a static PWA. The production build contains prerendered HTML, JavaScript, the Jazz WASM/runtime assets, the web manifest, and a revisioned service worker. ```mermaid flowchart LR Source["Application source"] --> Doctor["doctor + test"] Doctor --> Build["deno task build"] Build --> Fingerprint["source fingerprint"] Build --> Precache["service-worker precache"] Build --> Scan{"secret scan passes?"} Scan -- "no" --> Stop["Stop and remove leak"] Scan -- "yes" --> Dist["dist/"] Dist --> Preview["Local preview"] Dist --> Deno["Deno Deploy"] Dist --> Static["Other static host"] ``` ## Build and preview ```sh deno task build deno task preview ``` `build` writes `dist/`, records a source fingerprint in `dist/lofi-build.json`, generates the precache list, and scans for server-secret values. Before Astro starts, the shared doctor preflight validates the author-owned manifest and referenced assets. After Astro finishes, build verifies the emitted HTML links, manifest, icons, nested routes, worker revision/scope, build identity, and exact precache set together. `preview` refuses to start when the build identity is missing or invalid. To use another preview port: ```sh deno task preview --port 4173 ``` ## Configure the public application surface Before deploying, review: - `src/app.ts` — name, database namespace, stable credential origins, and repository URL; - `public/manifest.webmanifest` — stable install identity, locale, icons, colors, launch behavior, and shortcuts; - `public/favicon.svg` and any added icon files; - page titles, descriptions, and starter copy; - `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync. The default deployment base is `/`. When a static host mounts the application below the origin root, set the path once in `.env` before running `dev` or `build`: ```dotenv LOFI_BASE_PATH=/field-notes/ ``` The value must be an absolute path, not an origin. Lofi feeds it into Astro's `base` setting and uses the resulting base for public asset links, the manifest, the service worker, its scope, build identity, and local preview. Upload the contents of `dist/` to that same mount point. A root build and a subpath build are different deployment artifacts; rebuild after changing `LOFI_BASE_PATH`. Run `deno task doctor` and `deno task test` before the production build. ### Customize the web manifest before launch Treat `public/manifest.webmanifest` as product source, not generated build metadata. Before users install the app: - replace `name`, `short_name`, `description`, `id`, `lang`, and `dir` with product values; - choose an `id` URL token that will remain stable for the lifetime of the installed app; unlike `start_url`, it identifies the app and does not need to be a navigable page; - keep `scope`, `start_url`, every shortcut URL, and every shortcut icon aligned with the deployed base path; - replace the regular, maskable, Apple touch, and transparent monochrome icon assets while keeping their purposes intact; - keep `orientation: "any"` unless the product genuinely needs a screen-orientation lock; and - replace or remove the starter `Open tasks` shortcut when replacing the task example. After launch, changing the name or start page is routine; changing `id` can make a browser treat the manifest as a different installed application. Choose it once. If the product ships in multiple languages, keep the default strings plus `lang` and `dir`, then add language maps such as `name_localized`, `short_name_localized`, and `description_localized`. Localized values whose text direction differs from the manifest default should declare their own `dir`. Storefront metadata is optional and product-specific. Add lowercase `categories` only when they truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC certification code; never copy a placeholder rating. Experimental capabilities such as file, protocol, share-target, and launch handlers are deliberately absent from the starter and should be added only with matching product behavior and tests. Use the [installed-app recipe catalog](recipes/README.md) for the supported opt-in patterns. ### Replace or remove install presentation The starter manifest includes one labeled `540x720` narrow screenshot, one labeled `1280x720` wide screenshot, and one **Open tasks** shortcut. They are examples of the real generated app, not generic promotional art. Before launch: - replace both screenshots with current product captures and update each `sizes`, `label`, and `form_factor`, or remove the entire `screenshots` member and both files; - replace the shortcut name, description, route, and icon with one useful product entry point, or remove the entire `shortcuts` member; - keep shortcut routes inside manifest scope and backed by a prerendered route so they cold-start offline; and - keep shortcut icons inside scope with truthful MIME types and dimensions. Build validation checks every referenced asset, requires labeled narrow and wide variants when the `screenshots` member is present, and rejects shortcut routes that were not emitted. Screenshots are deliberately excluded from the required shell precache; deleting promotional captures cannot break offline startup. The starter does not claim a Web Share Target. If a product opts in, follow the tested [Web Share recipe](recipes/web-share.md): first add a same-scope, prerendered action route, then add a manifest member such as: ```json { "share_target": { "action": "./share/", "method": "GET", "enctype": "application/x-www-form-urlencoded", "params": { "title": "title", "text": "text", "url": "url" } } } ``` Use `parseTextShareTarget()` in the receiving island to ignore unknown parameters, reject duplicate or oversized values, and accept a shared URL only after parsing it and allow-listing `https:` and `http:`. Present the result as a draft: receiving a share is not user confirmation to persist it. Build validation rejects malformed declarations, POST/file shares without a matching worker recipe, and action routes that were not emitted for offline startup. ## Deno Deploy Create the static application once: ```sh deno task deploy:create --org --app ``` For later releases: ```sh deno task deploy ``` Both tasks build first and deploy `dist/` as the static root. ## Other static hosts Upload the contents of `dist/` to any host that can: - serve `index.html` at the application root; - preserve the manifest and WASM content types; - serve the application over HTTPS; - keep the service worker at the intended scope; - fall back to the appropriate prerendered HTML for application routes. Every prerendered route is included in the shell precache. While offline, a direct navigation such as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not emitted, the worker falls back to the cached application root. ### Offline cache policy The build's precache manifest contains required shell resources only. Shell assets are fetched bypassing the HTTP cache (matching the manifest's own `no-store` fetch), so a new revision can never be populated from stale HTTP-cached responses. If any listed response cannot be fetched, service-worker installation fails and reports a precache error rather than exposing a worker that cannot cold-start the application. Product-specific optional resources do not belong in that manifest. Cache names carry the registration scope, and lookups consult only the worker's own caches: apps served from different base paths on one origin never read, shadow, or delete each other's caches, and activation prunes only the current scope's previous revisions. Requests carrying a `Range` header are left to the network — a cached complete response cannot answer a partial request. Runtime caching is a separate, best-effort policy. It accepts only successful, public, same-origin responses inside the worker scope for fonts, images, scripts, styles, and workers. Navigation, cross-origin requests, partial responses, `private` or `no-store` responses, and `Vary: *` responses are not added. The cache retains at most 64 entries, moves refreshed URLs to the end, evicts the oldest inserted overflow, and removes the current scope's previous build revisions during activation. Navigation preload remains disabled: generated routes and their assets are precached, so starting a parallel network request before the normal cache-first lookup would spend bandwidth on the expected offline-ready path. Jazz sync, OPFS storage, background sync, and push remain outside the worker. ### Install and update lifecycle The optional `PwaActions` UI uses a browser prompt only while `beforeinstallprompt` is actually available. iOS receives its Share-menu steps. Other secure browsers with service-worker support get generic browser-menu guidance that says to use **Install app** or **Add to Home Screen** only if the browser offers it; an insecure or unsupported context is reported separately. When the app returns to the foreground, restores from the back-forward cache, or reconnects, Lofi asks the active service-worker registration to check for an update. Checks share one in-flight request, time out, and are rate-limited. Update state moves through `checking`, `installing`, `ready`, and `applying`; a waiting worker activates only after the user chooses **Update app**. Once the new worker takes control, every claimed tab reloads — the new worker prunes the old revision's caches, so a document left on the old HTML and module graph would go stale — not only the tab that chose the update. The first claim of a previously uncontrolled page does not reload, and a document reloads at most once, so ordinary worker changes cannot create a reload loop. Foreground recovery reconnects managed sync only while the current account has elected sync and the development inspector has not paused the transport; a configured server alone is not consent to resume replication. A runtime-cache write error is best-effort and leaves the active worker ready. Registration, required precache, and activation failures remain worker failures; update-check failures leave the current worker running and retry on a later foreground signal. Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or `BACKEND_SECRET` as public environment variables. ## Stable origins matter Durable storage and service workers require a secure context outside localhost. WebAuthn credentials also bind to the hostname. Choose the permanent production hostname before relying on device credentials, add it to `credentialOrigins`, and avoid redirect or preview URLs that change between deployments. ## Release verification On the deployed HTTPS URL: 1. Confirm the device capability panel passes. 2. Add data, reload, and restart the browser. 3. Install the PWA and perform an offline cold start. 4. If sync is configured, opt in with a throwaway account and verify another device can recover it. 5. Inspect the built application for the expected version/source fingerprint. --- # Application-origin migration Changing a production app from one origin to another creates a new browser security boundary. An HTTPS redirect moves navigation; it does not move the old origin's service workers, Cache Storage, IndexedDB, OPFS, permissions, installed-app identity, or WebAuthn credentials. This playbook keeps the old origin available while each user deliberately restores account access or imports a product-owned export, verifies the new origin, and only then removes the old installation. It never copies account secrets, passkeys, opaque IndexedDB/OPFS files, or browser permission state. ## Choose the migration contract Record these decisions before deploying the new origin: | Decision | Required choice | | ----------------------- | -------------------------------------------------------------------------------- | | Old-origin availability | A dated compatibility window long enough for offline users to return | | Data path | Managed account sync/recovery, or a versioned product-owned export/import format | | Manifest identity | A deliberate new same-origin `id`, `start_url`, and `scope` | | Credentials | New stable hostname/RP ID and a new-origin enrollment/recovery ceremony | | Install UX | Whether old and new installations coexist and how each is labeled | | Retirement | Warning dates, final read-only date, rollback owner, and support path | Do not silently add the new hostname to `credentialOrigins`. A WebAuthn credential is scoped by its RP ID and origin checks; changing the permanent hostname is an identity decision, not a redirect configuration detail. Keep lofi's checks intact and require an explicit new-origin recovery or enrollment flow. ## Pick one data path ### Managed account sync and recovery 1. On the old origin, reconnect and wait until the UI reports global durability for the latest write. 2. Make sure the user has a portable recovery phrase. An old-origin passkey alone cannot establish the same RP ID on a different hostname. 3. Open the new origin directly, create its fresh local browser storage, and restore the account with the supported recovery ceremony. 4. Wait for synced data to appear, then compare representative counts and named records. 5. Make a harmless test edit on the new origin and verify global durability and another-client convergence before treating the migration as complete. Never put an account secret, recovery phrase, or serialized Jazz/browser database in a URL, redirect, query parameter, cross-origin message, analytics event, or support log. ### Product-owned export and import 1. Export only a documented product schema from the old origin. Version it, bound its size, and omit credentials, opaque storage records, cache entries, handles, and permission grants. 2. On the new origin, validate the file as external input and show an import preview. 3. Require explicit confirmation before writing it to the new local database. 4. Compare representative counts and records, reload, go offline, and verify the imported data again. 5. Preserve the export until the compatibility window closes. The [file handling recipe](recipes/file-handler.md) provides a preview-only validation boundary, but an ordinary file picker remains the portable migration path. ## Deployment sequence 1. Deploy the new origin with its own manifest identity, icons, worker scope, database name, and stable credential-origin configuration. 2. Keep the old origin's application shell, worker assets, recovery/export UI, and support page available. Do not blanket-redirect these paths during the compatibility window. 3. Add old-origin messaging that links to the exact new HTTPS origin and explains that local data and permissions do not move automatically. 4. Let both installations coexist. Give the new installation distinct temporary labeling if the OS would otherwise make them ambiguous. 5. Ask for notifications, storage persistence, file/protocol associations, and other permissions again only when the user reaches the relevant new-origin feature. 6. After user verification, offer instructions to uninstall the old app. Never uninstall it or clear its storage programmatically as part of migration. ## Repeatable verification checklist Run this with a clean browser profile and with a profile containing representative production-like data. Retain dates and non-secret results with the release evidence. - [ ] `ORIGIN-01` Old origin opens online and offline with its retained worker/app shell. - [ ] `ORIGIN-02` New origin installs with the intended new manifest ID, scope, and display name. - [ ] `ORIGIN-03` New origin starts with separate IndexedDB, OPFS, Cache Storage, and permissions. - [ ] `ORIGIN-04` Chosen sync/recovery or export/import path transfers only product-owned data. - [ ] `ORIGIN-05` Representative counts and named records match before the old app is removed. - [ ] `ORIGIN-06` Reload and offline restart preserve verified data on the new origin. - [ ] `ORIGIN-07` A new-origin edit reaches its required durability and convergence state. - [ ] `ORIGIN-08` Old passkey behavior is rejected honestly; new recovery/enrollment succeeds. - [ ] `ORIGIN-09` Permissions and OS associations are requested or registered afresh as documented. - [ ] `ORIGIN-10` Old and new installations are distinguishable while they coexist. - [ ] `ORIGIN-11` Offline users returning during the window still receive migration instructions. - [ ] `ORIGIN-12` No secret or opaque browser-storage value appears in URLs, logs, or artifacts. ## Partial migration and rollback Migration is per user and per device. Track `not started`, `restored/imported`, `verified`, and `old installation removed` separately; never infer completion from a redirect or a visit. If verification fails, stop new-origin writes when practical, keep the old origin authoritative, and preserve the user's export/recovery material. Fix the new deployment or importer, then repeat from a fresh new-origin profile. Do not merge divergent local datasets automatically unless the product has a tested domain-level reconciliation strategy. Delay retirement when offline users have not had a reasonable return window, recovery/export remains broken, or support cannot distinguish partial states. At retirement, keep a minimal non-sensitive status/support page for the announced period, revoke old deploy credentials, and retain rollback artifacts. Removing DNS or the old worker too early strands users whose only current copy is local. --- # Command reference Run these from a generated project as `deno task `. | Task | Purpose | | ------------------- | ------------------------------------------------------------------- | | `dev` | Validate configuration and run the Astro development server | | `doctor` | Validate source PWA metadata and print secret-free readiness | | `test` | Run the deterministic generated-project test suite | | `build` | Build and validate the static PWA, fingerprint it, and scan secrets | | `preview` | Serve an existing production build locally | | `jazz:provision` | Create a managed Jazz app and write its configuration to `.env` | | `schema:validate` | Validate the Jazz schema and permission declarations | | `schema:deploy` | Publish the schema, migrations, and permissions | | `migrations:create` | Create a migration under `src/migrations/` | | `migrations:push` | Push migrations using the managed Jazz configuration | | `deploy:create` | Build and create a static Deno Deploy application | | `deploy` | Build and deploy a new production version to the configured app | ## Arguments ```sh deno task dev -- --host 0.0.0.0 deno task preview --port 4173 deno task jazz:provision --env .env --force deno task deploy:create --org --app ``` `dev` accepts Astro arguments after `--`. `preview` accepts `--port`. Provisioning accepts an alternate environment path and requires `--force` before replacing existing values. --- # Configuration reference ## Application configuration `src/app.ts` is author-owned: | Field | Purpose | | ------------------- | -------------------------------------------------------------------- | | `name` | Human-facing application name | | `databaseName` | Prefix for the durable local database namespace | | `schema` | Typed application schema exported by `src/schema.ts` | | `storage` | Durable storage policy; generated projects use `"durable"` | | `credentialOrigins` | Stable hostnames allowed for optional WebAuthn/device-credential use | | `passkey.rpId` | Canonical production hostname for recoverable account passkeys | | `sync.adapter` | Generated sync adapter selection | | `repositoryUrl` | Source/home link displayed by the starter | Choose `databaseName` and stable credential origins before shipping. Changing them later can change which local database or WebAuthn relying party the browser opens. Omitting `passkey.rpId` uses `location.hostname`. That is useful in local development, but each preview hostname becomes a separate passkey namespace. Pin the stable production RP-ID before offering passkey backup to real users. ## Environment configuration | Name | Classification | Behavior | | ------------------- | -------------- | ----------------------------------------- | | `LOFI_BASE_PATH` | Public build | Mount path; defaults to `/` | | `JAZZ_APP_ID` | Client-visible | Selects the managed Jazz application | | `JAZZ_SERVER_URL` | Client-visible | Selects its sync endpoint | | `JAZZ_ADMIN_SECRET` | Server-only | Used by schema and administrative tooling | | `BACKEND_SECRET` | Server-only | Reserved for server-side integration | No `.env` selects local-only mode. A complete `JAZZ_APP_ID` and `JAZZ_SERVER_URL` pair makes sync available. A partial pair is invalid and blocks affected commands. Process environment values take precedence over `.env`. Server-only values must never be prefixed as public variables, imported into client code, or committed. ## Build-time behavior The app is static, so public Jazz configuration is projected into the client during development or build. Changing the deployment environment requires a new production build. --- # Generated project layout The generated project is deliberately small: application source and branding are yours, while the pinned `@nzip/lofi` package supplies runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration. Package commands regenerate framework tooling and production output without copying framework source into the application. ```mermaid flowchart TB Root["my-app/"] Root --> Config["Generated configuration"] Root --> Public["Product assets"] Root --> Src["Author-owned source"] Root --> Tests["Application tests"] Root -. command generated .-> Tooling[".lofi/
ignored package tooling"] Root -. build generated .-> Dist["dist/
build output"] Package["one pinned @nzip/lofi version"] --> Tooling Package --> Dist Package --> Src ``` ## Exact source-controlled file map The following keys are extracted by the documentation drift test and compared with a real generated project. Adding or removing a generated file requires updating this map in the same change. | Path | Ownership category | Role | | ------------------------------------ | ----------------------- | -------------------------------------------------------------------------------------- | | `.env.example` | Generated configuration | Names optional public sync settings and server-only secrets without values. | | `.gitignore` | Generated configuration | Keeps secrets, dependencies, package tooling, builds, and failure artifacts untracked. | | `README.md` | Generated configuration | Gives the generated app's first commands, package boundary, and hosting path. | | `deno.json` | Generated configuration | Pins one lofi package version and exposes the supported task surface. | | `public/apple-touch-icon.png` | Product asset | Supplies the replaceable 180x180 iOS Home Screen icon. | | `public/favicon.svg` | Product asset | Supplies the replaceable vector browser icon and source mark. | | `public/icon-192.png` | Product asset | Supplies the regular 192x192 install icon. | | `public/icon-512.png` | Product asset | Supplies the regular 512x512 install icon. | | `public/icon-maskable-512.png` | Product asset | Supplies a padded 512x512 maskable icon with a safe-zone-aware mark. | | `public/icon-monochrome.svg` | Product asset | Supplies the transparent silhouette used where the OS applies a solid icon color. | | `public/screenshot-narrow.png` | Product asset | Supplies a replaceable labeled phone-sized install screenshot. | | `public/screenshot-wide.png` | Product asset | Supplies a replaceable labeled desktop-sized install screenshot. | | `public/manifest.webmanifest` | Product asset | Declares stable install identity, locale, scope, orientation, icons, and shortcuts. | | `src/app.ts` | Author-owned source | Composes the app name, storage, sync, credential origins, and repository link. | | `src/env.d.ts` | Generated configuration | Adds Astro and lofi build-time environment types. | | `src/islands/AccountGate.tsx` | Author-owned source | Presents opt-in account backup, sync, and recovery controls. | | `src/islands/TaskList.tsx` | Author-owned source | Implements the replaceable starter task experience. | | `src/islands/use-tasks.ts` | Author-owned source | Binds the starter task table to Preact through public package APIs. | | `src/layouts/Shell.astro` | Author-owned source | Owns document metadata, install links, and package boot. | | `src/pages/index.astro` | Author-owned source | Composes the starter page and package-owned optional UI. | | `src/permissions.ts` | Author-owned source | Declares application read and mutation policy. | | `src/schema.ts` | Author-owned source | Declares persisted application tables and fields. | | `src/styles/global.css` | Author-owned source | Styles the starter page, islands, and optional package surfaces. | | `tests/auth_e2e_test.ts` | Application test | Demonstrates an opt-in browser credential round trip. | | `tests/author-boundary_test.ts` | Application test | Prevents framework plumbing from returning to author UI. | | `tests/backup_migration_e2e_test.ts` | Application test | Proves local rows survive sync election and the phrase guard stays pinned. | | `tests/convergence_e2e_test.ts` | Application test | Demonstrates the optional two-client synced browser journey. | | `tests/testing-contract_test.ts` | Application test | Type-checks the public local-first browser helpers. | | `tsconfig.json` | Generated configuration | Enables strict Astro and Preact source checking. | ## Regenerated and ignored output These paths are not source-controlled generated files. Package commands recreate them as needed. | Path | Ownership category | Role | | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `.lofi/` | Ignored package tooling | Materialized package runtime plus Astro/Vite/Jazz configuration used by `dev` and `build`. | | `dist/` | Build output | Static production shell, hashed assets, install icons, build identity, precache manifest, and package-emitted `sw.js`. | Generated applications do not contain `src/_lofi/`, a source `public/sw.js`, or a checked-in `astro.config.ts`. Upgrade the single pinned `@nzip/lofi` version to change framework behavior; edit the author-owned source and product assets to change the application. --- # Account and access API ## Recoverable accounts The root `@nzip/lofi` export owns principal replacement and runtime cleanup: | API | Purpose | | -------------------------------------------- | ------------------------------------------------------------------------------------- | | `readAccountSession()` | Opens the runtime and returns the stable `user_id` and account state. | | `createRecoverablePasskeyBackup()` | Backs up the active Jazz secret to a resident, user-verifying passkey. | | `restoreFromPasskey(options)` | Restores that secret, saves it for the app ID, replaces the runtime, and elects sync. | | `revealRecoveryPhrase()` | Returns the portable bearer-secret fallback. Do not persist or log it. | | `restoreFromRecoveryPhrase(phrase, options)` | Performs the same replacement lifecycle from the phrase. | Both restore APIs require `confirmLocalReplacement: true` when a different local-only account would be discarded. `RecoverablePasskeyError` maps unsupported browsers, cancellation, missing credentials, RP-ID mismatch, and verification failure to non-secret, actionable messages. `createBackupPasskey()` and `confirmPhraseAccess()` are retained compatibility APIs for a local phrase-reveal guard. That credential cannot restore an account. ## Access templates Import from `@nzip/lofi/access`: - Schema helpers: `sharedGrantTable`, `groupMembershipTable`. - Policy templates: `privateAccess`, `sharedAccess`, `groupAccess`, `defineAccessPolicies`. - Identities: `sharingIdentity`, `encodeSharingIdentity`, `decodeSharingIdentity`. - Operations: `createSharingOperations`, `createGroupOperations`. - Fixed roles: `reader`, `contributor`, `writer`, `admin`. Sharing operations provide `share`, `revoke`, `listShares`, and `sharedWithMe`. Group operations provide `createGroup`, `addMember`, `changeRole`, `removeMember`, `leaveGroup`, and `listMembers`. Collaboration operations reject local-only use with `AccessError.code === "sync-required"`. In pinned Jazz alpha.53, the permission authority does not expose a group inserted earlier in the same transaction to the first membership's `allowedTo.update("groupId")` check. `createGroup` therefore serializes the creator-owned group write and first-admin membership write, and attempts a compensating group delete if the membership is rejected. The compensating delete is authorized by the creator's direct delete authority on their own group rows, so a failed bootstrap does not strand an orphan group. This is secure, but it is not database-transaction atomic. Do not describe it as atomic until Jazz can validate that relationship against staged transaction rows. The group creator additionally holds a permanent superseat — see [Permission templates](../permissions.md#fixed-group-roles) and [the decision record](../decisions/group-creator-authority-alpha53.md). The templates compile ordinary Jazz policies and accept a final raw-policy callback. They do not replace `s.table`, `s.defineApp`, or raw `s.definePermissions`; use those directly when the fixed models do not fit. --- # Local-first accounts: open now, back up later This document explains the account and custody model. For project setup and a shipping checklist, see [Sync and recovery](sync-and-recovery.md). lofi's identity model is **local-first**. A new app opens immediately on a private, on-device account — no sign-in, no ceremony, nothing sent anywhere. From there the user can _elect_ to back up and sync that account, and recover it on another device. Electing to sync preserves everything created while local-only, because the account identity never changes. This is the pit of success: the app works the instant it loads, and the path to a durable, multi-device account is one opt-in click — not a wall the user hits before they can do anything. ## The three states 1. **Local-only (first boot).** The runtime opens a random per-device account (`BrowserAuthSecretStore.getOrCreateSecret`) and never waits. Writes are durable on the device; nothing reaches the network. This is the whole experience for a purely local app. 2. **Backed up & syncing (elected).** When a managed Jazz app is configured, the user elects to back up and sync. Replication turns on, the existing local data pushes up under the _same_ identity, and a recovery phrase becomes available. 3. **Recovered (in a fresh browser).** A recoverable passkey or the recovery phrase reconstructs the exact account secret. The old runtime is shut down, the new principal is opened with sync, and data that reached the sync provider flows back down. The **AccountGate** island (`src/islands/AccountGate.tsx`) drives states 2 and 3. It renders only when a Jazz app is configured, because a recovery phrase only matters once there is somewhere to sync to. ## How the account survives the upgrade A lofi account _is_ a 32-byte secret. The Jazz account id is a function of that secret, so the same secret always opens the same account — whether or not a sync server is attached: ``` local-only: createDb({ appId, secret }) → account X, on this device synced: createDb({ appId, secret, serverUrl }) → account X, now replicated ``` Electing to sync recreates the runtime with `serverUrl` added. Because the secret (and thus the account id) is unchanged, every write made while local-only still belongs to account X — no new account, and nothing for the user to re-enter. Internally, the election is one durable record: lofi opens the account's managed namespace, copies the rows written while local-only into it (waiting only for local durability, so the copy also completes offline), and replicates them up through normal sync once connected. ## The recovery phrase is the portable backup The same 32-byte secret encodes as a 24-word recovery phrase (`RecoveryPhrase.fromSecret` / `.toSecret`, via `jazz-tools/passphrase`): ``` recovery phrase = words( account secret ) # the phrase *is* the account ``` - **Back up** → create a recoverable passkey and reveal the phrase once. Both represent the exact same account secret. - **Recover with a passkey** → the browser/provider releases the resident credential's secret after user verification → the same account opens. - **Recover with the phrase** → type the words on a compatible device → the same account opens. The phrase is the portable bearer-secret fallback and does not depend on a passkey provider. Restoring replaces whatever account the device currently holds, so the UI confirms before discarding a local-only account's un-backed-up data. ### Two passkey credentials with different jobs `createRecoverablePasskeyBackup` uses Jazz's passkey-backup implementation to place the 32-byte account secret in a resident, user-verifying platform credential. `restoreFromPasskey` can recover the account from that credential. Availability follows the configured RP-ID and the browser, operating system, and passkey provider; lofi does not promise universal cross-provider or cross-platform portability. The older `createBackupPasskey` API is different: it enrolls a credential that only confirms a later `confirmPhraseAccess` ceremony on the same browser. It does not contain a recoverable account backup. The reference UI labels it a **local phrase-reveal guard** so the two credentials cannot be confused. ### Boot flow - **First boot** → local-only account opens immediately. No gate. - **Back up & sync** → create a recoverable passkey when supported → reveal the recovery phrase to save → the user confirms the phrase is saved → sync turns on and the runtime recreates (a page reload), and the account replicates. Sync is never enabled while the phrase is still unread. - **Return boots** → the same cached secret opens the same account, synced or not. - **A fresh browser** → explicitly confirm replacement → restore from passkey or phrase → the old subscriptions, stores, workers, cached clients, and Jazz client shut down → the restored account starts and synced rows can arrive. - **Stop syncing** → detaches the network and returns to local-only; the account and data are untouched, and electing again resumes against the same account. The framework side is exported by `@nzip/lofi` (`readAccountSession`, `createRecoverablePasskeyBackup`, `restoreFromPasskey`, `revealRecoveryPhrase`, `enableSyncBackup`, `stopSyncBackup`, `restoreFromRecoveryPhrase`). The gate is the author-owned example you can restyle or replace. ## Custody and recovery — stated plainly - **lofi does not run a recovery service.** The user holds the phrase; a recoverable passkey is held by the browser/platform provider. The Jazz sync service cannot reconstruct the account secret. - **Provider sync has boundaries.** A passkey may sync inside one provider ecosystem, but RP-ID, account, browser, operating-system, and provider rules decide availability. Keep the phrase. - **Lose every secret-bearing device, recoverable passkey, and phrase → lose the account.** Synced ciphertext alone does not restore identity authority. The exact pinned dependency audit and trust assumptions are retained in [the passkey decision record](decisions/passkey-backup-alpha53.md). ## Provisioning sync A managed Jazz app supplies `JAZZ_APP_ID` / `JAZZ_SERVER_URL` (the public pair the runtime reads) plus server-only secrets. Generate one and write `.env` in one step: ```sh deno task jazz:provision # existing project deno run -A jsr:@nzip/lofi/create --sync my-app # at scaffold time ``` The generated app is unclaimed — claim it from the Jazz dashboard within the grace window shown, or it expires. `.env` holds the server-only secrets and is git-ignored. ## Optional: protecting the secret at rest The package auth runtime is a standalone device-credential primitive: enroll a WebAuthn passkey and derive a credential-bound key (PRF) to encrypt data — including the account secret — **at rest**. It is feature-detected and never faked. It is _not_ the account identity (deriving the account from a credential was removed, since a derived key is a different account and cannot carry local-only data forward). See [Advanced device auth](auth.md). --- # lofi device auth Status: **primitive validated in reference source; `./auth` subpath deferred to a later milestone**\ Scope: **device-local WebAuthn + PRF at-rest key derivation** > **Advanced runtime seam.** Generated applications consume this through the supported root package > export, and normal product work should not reimplement it. Use this document only when > deliberately evaluating the optional device-credential primitive. Account sync and recovery are > documented separately in [Sync and recovery](sync-and-recovery.md). lofi device auth is a small, honest **primitive**, not a mandated identity or recovery scheme. Local-first identity is device-local and cryptographic — there is no central store to authenticate against — so this module does exactly three things: enroll a device passkey, authenticate with it, and derive a credential-bound key to encrypt data **at rest**. The generated session runtime handles accounts, recoverable-passkey backup, recovery phrases, and optional multi-device sync separately. The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lofi`. ## What it guarantees - **Enrollment is gated on a stable origin.** A passkey binds to its origin hostname (its RP-ID); if that origin later changes, the credential silently breaks. `classifyCredentialOrigin` reports the current origin as `stable`, `local-only`, `unverified`, or `blocked`, and enrollment refuses anything but `stable` or `local-only`. Authors declare their committed hostnames in `src/app.ts` `credentialOrigins` (exact, or a `*.` suffix pattern); a deployed host that is not listed is `unverified` and refused — there is no implicit trust of the served hostname, so a preview origin cannot mint credentials that strand on the production one. `localhost` and `127.0.0.1` are `local-only` — useful for development, never a shipping RP-ID. - **The phrase-guard ceremony is pinned to its enrolled credential.** Authentication passes `allowCredentials` for the enrolled credential id and verifies the asserting credential against it, failing with `credential-mismatch` otherwise — another resident passkey for the same RP-ID cannot satisfy the guard. Guards enrolled before pinning remain discoverable until re-enrolled. - **PRF derives an at-rest key, and is never faked.** The WebAuthn PRF extension yields a 32-byte secret bound to the credential, from which `deriveAtRestKey` produces an AES-GCM key via HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or authenticator does not return a PRF result, the derive path throws `prf-unavailable` rather than inventing a key. - **No recovery claim for this primitive.** `enrollDeviceCredential` and the PRF-derived key do not contain the Jazz account secret, so they cannot restore an account. Account recovery uses the separate `createRecoverablePasskeyBackup` / `restoreFromPasskey` flow documented in [Sync and recovery](sync-and-recovery.md), with an RP-ID- and provider-independent phrase fallback. ## Usage ```ts import { deriveAtRestKey, derivePrfSecret, encryptAtRest, enrollDeviceCredential, getAuthCapability, } from "@nzip/lofi"; // 1. Feature-detect before offering enrollment. const capability = await getAuthCapability(); if (!capability.webAuthn || capability.origin.status !== "stable") { throw new Error(capability.origin.action); } // 2. Enroll a resident, user-verifying device passkey (once per device). const credential = await enrollDeviceCredential(); // 3. Derive a credential-bound PRF secret from a stable, app-owned salt. const salt = new TextEncoder().encode("notes.v1"); const prfSecret = await derivePrfSecret(salt); // 4. Turn the secret into an at-rest key and encrypt. Vary `info` to bind // unrelated data to different keys. const key = await deriveAtRestKey(prfSecret, "notes"); const blob = await encryptAtRest(key, new TextEncoder().encode("local-first secret")); console.log(credential.id, blob.iv.length, blob.ciphertext.length); ``` ## Testing - **Unit** (`package/runtime/auth_test.ts`): origin classification, enroll/authenticate, PRF result handling, error mapping, and the HKDF -> AES-GCM round-trip run without a browser by injecting a fake `CredentialsContainer` and an explicit `rpId`. - **Browser** (`tests/auth_e2e_test.ts` + `@nzip/lofi/testing`'s `withVirtualAuthenticator`): a CDP virtual authenticator drives an enroll -> authenticate round-trip headless. CDP virtual authenticators do **not** reliably model the PRF extension, so PRF derivation is not exercised there; it is feature-detected and validated on real devices. --- # Optional installed-app recipes These recipes add OS-facing PWA capabilities only when a product has a concrete use case. They are not part of the generated starter and unsupported browsers keep the normal web experience. ```mermaid flowchart LR Need["Concrete product need"] --> Support{"Support and fallback understood?"} Support -- "no" --> Web["Keep the normal web flow"] Support -- "yes" --> OptIn["Add manifest/runtime recipe"] OptIn --> Validate["Validate external input"] Validate --> Test["Test direct, installed, offline, and unsupported paths"] ``` ## Available - [Inbound and outbound Web Share](web-share.md) — share text or links through the OS sheet and receive an untrusted draft through an optional installed-app share target. - [Launch handling and client reuse](launch-handler.md) — safely focus or navigate an existing installed window while preserving ordinary launch behavior elsewhere. - [File handling and import previews](file-handler.md) — validate explicitly associated files into transient drafts before the user confirms persistence. - [Custom-protocol item links](protocol-handler.md) — parse one product-owned `web+` scheme into allow-listed collaborative-list identifiers without arbitrary redirects. - [Related-application discovery](related-app-discovery.md) — hide redundant companion onboarding from exact verified-app matches without changing authentication or PWA installability. - [Cross-origin app-window scope](scope-extension.md) — keep product-owned secondary-origin paths inside an installed window after reciprocal deployment verification. - [Desktop window controls overlay](window-controls-overlay.md) — use noncritical titlebar space without obscuring system controls, while preserving the standalone layout. ## Contract for every recipe - Keep the capability absent until the application opts in. - State the product use case and current browser/OS support. - Feature-detect and preserve a useful ordinary-web fallback. - Ask for permission only after a user action that explains the value. - Treat launch payloads, files, URLs, messages, and subscription data as untrusted input. - Explain whether an install, reinstall, manifest refresh, or worker update is required. - Test direct browser navigation, installed launch, offline startup, unsupported browsers, malformed input, and removal of the capability. Foreground recovery remains lofi's portable baseline. A recipe never moves Jazz sync or OPFS into the service worker without a separate architecture decision. --- # File handling and import previews Use this recipe when an installed desktop app should open a narrow, product-owned export format. The normal `` import remains the portable fallback. [Installed PWA file handling](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Associate_files_with_your_PWA) is experimental and currently limited to Chromium-based browsers on desktop operating systems. The recipe deliberately ends at an import draft: ```mermaid flowchart LR OS["OS file launch"] --> Queue["launchQueue"] Picker["Ordinary file picker"] --> Validate["Validate metadata and limits"] Queue --> Validate Validate --> Parse["Parse product-owned content"] Parse --> Preview["Show preview"] Preview --> Confirm{"User confirms?"} Confirm -- "yes" --> Persist["Product persists data"] Confirm -- "no" --> Discard["Discard transient draft"] ``` ## 1. Add a narrow manifest association The generated starter has no `file_handlers` member. Add one only for a concrete import format: ```json { "file_handlers": [ { "action": "./import/", "accept": { "application/json": [".field-notes.json"] } } ] } ``` Use exact MIME types and extensions—no wildcards. The action must be a prerendered route inside the manifest scope so the import screen can open offline. Browsers read this association during install; after changing it, refresh or reinstall the PWA and verify the operating-system association. ## 2. Use one validator for launches and the picker ```ts import { type FileImportDraft, installFileLaunchConsumer, prepareFileImportDrafts, } from "@nzip/lofi/recipes/file-handler"; type ExportPreview = { title: string; rows: readonly unknown[] }; const importOptions = { accept: { "application/json": [".field-notes.json"] }, maxFiles: 1, maxFileBytes: 256_000, async parse(file: File): Promise { const value: unknown = JSON.parse(await file.text()); if (!isFieldNotesExport(value)) throw new Error("invalid export"); return value; }, }; let preview: readonly FileImportDraft[] = []; const launched = installFileLaunchConsumer({ ...importOptions, onDrafts: (drafts) => { preview = drafts; renderImportPreview(drafts); }, onRejected: () => renderGenericImportError(), }); async function onPickerChange(files: FileList | null) { const result = await prepareFileImportDrafts(files ? [...files] : [], importOptions); if (result.ok) renderImportPreview(result.drafts); else renderGenericImportError(); } ``` Register one `launchQueue` consumer early on the action route. The recipe's shared ownership seam prevents an older development/HMR consumer from replacing a newer launch recipe. Do not register a second raw queue consumer in the same window. The helper treats handles, names, MIME types, extensions, sizes, access errors, and parsed content as untrusted. Rejections expose stable reason codes and an index, never file names or contents. A denied handle becomes `unreadable-file`; invalid and oversized inputs never produce a partial draft. ## 3. Confirm before persistence Keep returned drafts in transient component state. Render a preview and use a separate button such as “Import these notes” for the application-owned database write. Neither `prepareFileImportDrafts` nor `installFileLaunchConsumer` writes to Jazz, OPFS, the service worker, or the source file. Test direct navigation to the action route, an installed file launch, an ordinary picker import, offline startup, denied access, malformed content, invalid metadata, oversized files, cancellation, and a browser without `launchQueue`. Removing `file_handlers` should leave the picker flow intact; reinstall or refresh the app before verifying that the OS association disappeared. --- # Launch handling and client reuse Use this recipe when an installed single-window app should focus its existing client and deliberately route a new launch. The [`launch_handler` manifest member](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/launch_handler) and `Window.launchQueue` have limited, experimental browser support. Unsupported browsers retain their ordinary new-window or existing-window behavior. Do not use client reuse as an authentication boundary. A target URL is external input even when the browser obtained it from an installed-app launch. ## Choose a client mode Add this opt-in member to `public/manifest.webmanifest`: ```json { "launch_handler": { "client_mode": ["focus-existing", "auto"] } } ``` The ordered array gives supporting browsers a fallback: - `focus-existing` focuses an existing app window without navigating it. The app handles the target through `launchQueue`; if no client exists, the browser opens one. - `navigate-existing` focuses and navigates an existing client. Use it only when replacing that client's current view is expected. - `navigate-new` opens another client for every launch. - `auto` leaves the choice to the browser and is the portable final fallback. Lofi validates the optional member and rejects unknown or repeated modes. The starter omits it. ## Register the consumer early Create an island mounted from the root layout so every installed route can receive a queued launch: ```tsx import { useEffect, useState } from "preact/hooks"; import { type InstalledAppLaunchIssue, installInstalledAppLaunchConsumer, } from "@nzip/lofi/recipes/launch-handler"; export default function LaunchRouter() { const [supported, setSupported] = useState(); const [issue, setIssue] = useState(); useEffect(() => { const consumer = installInstalledAppLaunchConsumer({ scope: new URL(import.meta.env.BASE_URL, location.origin), onLaunch(target) { // target.url is proven same-origin and inside the configured path scope. const next = new URL(target.url); history.replaceState(null, "", `${next.pathname}${next.search}${next.hash}`); dispatchEvent(new PopStateEvent("popstate")); }, onRejected: setIssue, }); setSupported(consumer.supported); return consumer.dispose; }, []); if (supported === false) return null; // ordinary browser launch is the fallback if (issue) return

The requested app destination is not valid.

; return null; } ``` The browser retains launches until a consumer is registered, but mounting this island from only one leaf route would miss launches handled elsewhere in the app. The helper allows one active consumer per queue. A stale development/HMR cleanup cannot overwrite a newer generation, and disposing the current owner leaves a no-op consumer. The helper rejects missing or malformed targets, credential-bearing URLs, other origins, and paths outside the exact configured scope. Rejection reasons contain no received URL. Product routing must still decide whether the in-scope view exists and whether the current user may see it. ## Install, update, and fallback Manifest launch behavior may not update immediately on every platform. Test both a fresh install and an existing installation after changing `client_mode`; some platforms require reinstalling before the OS launch behavior changes. Removing `launch_handler` restores browser-selected launch behavior without changing lofi data. Do not force focus or close duplicate windows in unsupported browsers. Ordinary links, shortcuts, and direct navigations remain the complete fallback. ## Verification checklist 1. Launch with a valid same-origin URL inside the deployment base and route the existing client. 2. Reject another origin, an out-of-scope path, a credential-bearing URL, and malformed input with a fixed message that does not echo the target. 3. Confirm a browser without `launchQueue` follows its ordinary launch behavior without an error. 4. Register a new development generation, dispose the older one, and verify the new consumer remains active. 5. Test fresh install, existing install, update, offline launch, and removal of the manifest member. --- # Custom-protocol collaborative-list links Use this recipe when a companion tool needs to open one collaborative-list item in an installed PWA. The manifest surface is [experimental and not Baseline](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/protocol_handlers), so every shared item must also have an ordinary HTTPS deep link. ## 1. Declare one custom scheme The starter registers no protocols. Add one lowercase `web+` scheme and a same-scope, prerendered handler route: ```json { "protocol_handlers": [ { "protocol": "web+lofi", "url": "./open-item/?url=%s" } ] } ``` The validator requires exactly one `%s`, as the entire value of one query parameter. It rejects privileged schemes, duplicate protocols, extra members, fragments, escaped scope, and a handler route that is missing from the offline production output. Protocol registration and conflicts are browser- and OS-dependent. A browser may ask the user for confirmation, another application may remain the default, and manifest changes may require an app refresh or reinstall. To remove the capability, delete `protocol_handlers`, rebuild, refresh or reinstall the PWA, and verify the OS association separately. ## 2. Parse identifiers, not a redirect Use links shaped like: ```text web+lofi:collaborative-list/list_123/item/item_456 ``` On the action route: ```ts import { parseCollaborativeListProtocolTarget } from "@nzip/lofi/recipes/protocol-handler"; const result = parseCollaborativeListProtocolTarget(location.search, { protocol: "web+lofi", parameter: "url", maxLength: 512, }); if (result.ok) { showItemPreview(result.target.listId, result.target.itemId); } else { showGenericInvalidLink(); } ``` `URLSearchParams` performs the one query decode. The parser rejects remaining percent escapes rather than decoding a second time. It bounds the decoded URL, requires the exact configured scheme and the exact `collaborative-list/LIST/item/ITEM` shape, and allow-lists both IDs. It returns identifiers only; the received URL can never become `location.href`, a router destination, or proof of caller identity. Build the fallback HTTPS link from your own origin and the validated identifiers. Unsupported browsers and devices then open the same item through ordinary navigation. Test direct HTTPS navigation, a valid protocol launch, wrong schemes, malformed and double-encoded values, duplicate parameters, oversized input, offline action-route startup, fallback links, handler conflicts, and removal/reinstallation behavior. --- # Related-application discovery Use this recipe only when the product already owns a native or installed-web companion and has completed that platform's bidirectional verification. The [`getInstalledRelatedApps()` API](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps) is experimental, limited to top-level secure contexts in supporting browsers, and intended here only to avoid redundant presentation such as a companion-app onboarding banner. ## Manifest and verification The starter omits both members. Add the verified listing while leaving native preference false: ```json { "prefer_related_applications": false, "related_applications": [ { "platform": "play", "id": "com.example.companion", "url": "https://play.google.com/store/apps/details?id=com.example.companion" } ] } ``` Do not invent identifiers. Android requires Digital Asset Links; Windows, cross-scope PWAs, and other platforms have their own verification mechanisms. Manifest declaration alone is not proof of ownership. Keep `prefer_related_applications` false or omitted so Chromium retains PWA installability. ## Presentation-only discovery ```ts import { discoverRelatedApplications } from "@nzip/lofi/recipes/related-app-discovery"; const discovery = await discoverRelatedApplications({ allow: [{ platform: "play", id: "com.example.companion", url: "https://play.google.com/store/apps/details?id=com.example.companion", }], }); const showCompanionOnboarding = discovery.status !== "installed"; ``` Only exact allow-list matches are returned. Browser-supplied versions, unknown apps, and extra fields are discarded. Unsupported, empty, malformed, embedded-context, and rejected calls all retain the normal PWA experience. Never use discovery for authentication, authorization, account linking, feature entitlements, or proof that the current user controls the companion app. Test unsupported and empty results, exact and mismatched platform IDs/URLs, API rejection, top-level versus embedded contexts, privacy-safe UI, PWA installability, platform verification updates, and removing the manifest listing. --- # Cross-origin app-window scope Use this recipe only when one product owns both an installed PWA and a separate HTTPS origin that should remain inside the installed app window, such as product-owned help or a regional surface. [`scope_extensions`](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/scope_extensions) is experimental. It changes window presentation after both origins opt in; it does not join their security, storage, or service-worker boundaries. The generated starter stays single-origin and omits this capability. ## Declare the extending origin Add an exact origin to the primary app's `manifest.webmanifest`: ```json { "id": "https://app.example.com/notes", "scope_extensions": [ { "type": "origin", "origin": "https://help.example.com" } ] } ``` The origin must be HTTPS and contain no credentials, path, query, or fragment. Lofi's manifest validation rejects malformed, duplicate, and invented entries. ## Publish reciprocal ownership On `https://help.example.com`, publish this JSON at the exact path `/.well-known/web-app-origin-association`: ```json { "https://app.example.com/notes": { "scope": "/notes/" } } ``` The key is the exact absolute manifest ID, not merely the app origin. The value limits which paths on the extending origin receive app-window presentation. Generate and verify it at deploy time: ```ts import { createScopeExtension, createWebAppOriginAssociation, verifyWebAppOriginAssociation, } from "@nzip/lofi/recipes/scope-extension"; const declaration = createScopeExtension("https://help.example.com"); const expected = { manifestId: "https://app.example.com/notes", scope: "/notes/", }; const association = createWebAppOriginAssociation(expected); // After fetching the deployed JSON in trusted deployment tooling: if (!verifyWebAppOriginAssociation(deployedJson, expected)) { throw new Error("reciprocal scope association is missing"); } ``` Do not fetch the association from application runtime code. A true verification result confirms deployment configuration only; it is not authentication or authorization. ## Deploy, test, and revoke Deploy in this order: 1. Publish the association on the extending origin with JSON content type, HTTPS, and no redirect. 2. Fetch that exact well-known URL without credentials and verify the exact manifest ID and scope. 3. Add `scope_extensions` to the primary manifest, then allow browser manifest caches to refresh. 4. Test a fresh install and an existing install on supported and unsupported browsers. During a partial rollout, unsupported browsers, a missing association, or a failed ownership check, links continue as ordinary out-of-scope navigation and may open browser UI. Build the feature so that external navigation is useful. For revocation, first remove the primary manifest declaration and let installed clients refresh; then remove the remote association. The primary service worker cannot control or cache the extending origin. Offline navigation there requires that origin's own offline design. The origins retain separate cookies, storage, passkey relying-party behavior, Jazz configuration, CSP, CORS, authentication, and authorization. Never use scope extension as a way to share any of them or to bypass an origin boundary. Test exact and mismatched manifest IDs, invalid and duplicate origins, path-limited navigation, ordinary external fallback, partial rollout, revocation, offline navigation, manifest refresh, and passkey/storage/Jazz isolation before shipping. --- # Inbound and outbound Web Share Use this recipe when a user should explicitly share a small text/link payload through the OS share sheet or open an installed lofi app with shared text as an uncommitted draft. The [outbound Web Share API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API) and inbound [`share_target` manifest member](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/share_target) have limited browser support. Both require a secure deployed context; the inbound target appears only after installation. Unsupported browsers must retain an ordinary copy/link or form flow. ## Outbound share Import the isolated recipe entrypoint. Call it directly from the click handler so the browser still has transient user activation: ```tsx import { shareOrFallback } from "@nzip/lofi/recipes/web-share"; ; ``` The helper calls `navigator.canShare()` when present. It falls back only when native sharing is missing or rejects the payload before opening. Cancellation is a normal result; a runtime failure does not silently copy data after the native sheet may already have opened. If Clipboard is also unavailable, make the fallback reveal a normal selected text field or link instead of claiming that copying succeeded. ## Inbound text/link target Add an explicit opt-in member to `public/manifest.webmanifest`: ```json { "share_target": { "action": "./share/", "method": "GET", "enctype": "application/x-www-form-urlencoded", "params": { "title": "title", "text": "text", "url": "url" } } } ``` This recipe intentionally accepts text and HTTP(S) links with GET. POST/file shares require worker request handling, temporary storage, file validation, and a redirect; lofi does not pretend that the baseline worker provides those behaviors. Create `src/pages/share.astro` as a prerendered route: ```astro --- import ShareReceiver from "../islands/ShareReceiver.tsx"; import Shell from "../layouts/Shell.astro"; --- ``` Parse the current query only in the browser. The parser ignores unknown parameters, rejects duplicates and over-limit values, and accepts only absolute HTTP(S) URLs: ```tsx import { useEffect, useState } from "preact/hooks"; import { parseTextShareTarget, type TextShareTargetResult } from "@nzip/lofi/recipes/web-share"; export default function ShareReceiver() { const [result, setResult] = useState(); useEffect(() => { setResult(parseTextShareTarget(location.search)); }, []); if (!result) return

Opening shared draft…

; if (!result.ok) return

This shared content is not valid.

; return (
{ event.preventDefault(); // Persist result.draft only here, after the user confirms. }} >

Review shared content

{result.draft.title &&

{result.draft.title}

} {result.draft.text &&

{result.draft.text}

} {result.draft.url && {result.draft.url}}
); } ``` Change the default parameter names or limits only when the manifest and parser options change together. Never render a received URL as HTML, use it as an arbitrary redirect, or persist the draft on page load. ## Build, install, and remove `deno task doctor` validates the member shape and same-scope action. `deno task build` also requires the action to map to a prerendered route, which puts that HTML and its assets in the offline shell. Manifest capability changes do not propagate uniformly across installed platforms. After adding or removing the target, verify a fresh install and an existing installation; some platforms require reinstallation before the OS share sheet changes. Removing the member and receiver route restores the ordinary web app without affecting stored lofi data. ## Verification checklist 1. Open `/share/` directly with no query and show a fixed invalid-draft message. 2. Open it with valid title, text, and HTTP(S) URL values and require confirmation before saving. 3. Reject duplicate, malformed, non-HTTP(S), and over-limit values without echoing them in errors. 4. Install the app, share into it from another app, and verify an offline cold start of the route. 5. Exercise outbound shared, cancelled, unsupported/fallback, and runtime-failed outcomes. 6. Remove `share_target`, rebuild, and confirm the rest of the PWA remains installable and offline. --- # Desktop window controls overlay Use this experimental recipe when a desktop productivity app has noncritical navigation that earns the titlebar space. Window Controls Overlay is not Baseline and is currently limited to some desktop browsers. The generated app remains fully usable in `standalone` mode without it. ## Manifest opt-in and fallback Keep the portable fallback in `display` and put the experimental mode first in `display_override`: ```json { "display": "standalone", "display_override": ["window-controls-overlay"] } ``` The override matters only for an installed desktop app in a supporting browser. A user can toggle the overlay, so feature presence never means it is visible. Existing installs may require a manifest refresh, browser restart, or reinstall before a changed display mode takes effect. ## Collision-free layout Let the browser-provided physical geometry define the usable titlebar area. Keep the ordinary header as the default, then reposition only while the display mode is active: ```css .app-titlebar { min-block-size: 3rem; display: flex; align-items: center; gap: 0.75rem; border-block-end: 1px solid CanvasText; } @media (display-mode: window-controls-overlay) { .app-titlebar { position: fixed; left: env(titlebar-area-x, 0px); top: env(titlebar-area-y, 0px); width: env(titlebar-area-width, 100%); height: env(titlebar-area-height, 3rem); min-block-size: 0; app-region: drag; } .app-titlebar :is(a, button, input, select, textarea) { app-region: no-drag; } main { padding-block-start: env(titlebar-area-height, 3rem); } } @media (display-mode: window-controls-overlay) and (max-width: 32rem) { .app-titlebar .noncritical-action { display: none; } } @media (forced-colors: active) { .app-titlebar { border-color: CanvasText; } } ``` Do not assume controls are on the right: macOS, Windows, browser UI, RTL, zoom, and resizing can all change the available physical rectangle. The `titlebar-area-*` variables already exclude system window controls. Never position content outside their rectangle or cover system-critical controls. Only noninteractive empty space should be draggable. Links, buttons, menus, inputs, and focus rings must remain keyboard reachable and `no-drag`. Preserve a visible title and border in high-contrast mode, do not communicate state by color alone, and remove noncritical actions before compressing interactive targets in a narrow window. ## Runtime geometry CSS should own layout. Observe geometry only when application state genuinely needs it: ```ts import { observeWindowControlsOverlay } from "@nzip/lofi/recipes/window-controls-overlay"; const observer = observeWindowControlsOverlay({ onGeometry({ visible, titlebarArea }) { document.documentElement.toggleAttribute("data-overlay-visible", visible); console.debug("available titlebar width", titlebarArea.width); }, }); // During component teardown: observer.dispose(); ``` The observer feature-detects the API, reports initial and changed geometry, and stops cleanly. The standalone header remains the fallback when unsupported. Geometry events can fire rapidly while resizing, so avoid synchronous layout reads/writes in the callback and coalesce expensive work. Test installed and ordinary browser windows, overlay on/off, resize and zoom, controls on both sides, LTR and RTL, narrow width, keyboard-only use, drag/no-drag regions, high contrast, offline startup, manifest updates, and uninstall/reinstall. ## Tabbed display: no ship Lofi does not expose `display_override: ["tabbed"]` or `tab_strip`. As of this evaluation, tabbed mode remains an incubation proposal without a credible cross-browser installed-app test matrix. Its home tab rules can also redirect ordinary same-document navigation into other application contexts, which needs product-specific lifecycle testing. The manifest validator reports it as lacking a tested lofi recipe. Continue using ordinary in-app document navigation, browser tabs, or separate standalone windows until implementations and automation support make the semantics portable and testable. References: [MDN Window Controls Overlay API](https://developer.mozilla.org/en-US/docs/Web/API/Window_Controls_Overlay_API), [WICG Window Controls Overlay draft](https://wicg.github.io/window-controls-overlay/), and the [WICG tabbed-mode explainer](https://wicg.github.io/manifest-incubations/tabbed-mode-explainer.html). --- # Direct sharing example Define the raw resource and the conventional grant table: ```ts import { schema as s } from "jazz-tools"; import { sharedGrantTable } from "@nzip/lofi/access"; export const app = s.defineApp({ notes: s.table({ title: s.string(), body: s.string() }), noteGrants: sharedGrantTable("notes"), }); ``` Apply the narrow policy: ```ts import { defineAccessPolicies, sharedAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [ sharedAccess({ resource: app.notes, grants: app.noteGrants }), ]); ``` Create typed operations and pass the recipient's non-secret, app-scoped sharing identity: ```ts import { createSharingOperations } from "@nzip/lofi/access"; import { app } from "./schema.ts"; const notes = createSharingOperations({ resource: app.notes, grants: app.noteGrants }); await notes.share(note.id, recipientIdentity, "read"); await notes.share(note.id, recipientIdentity, "edit"); await notes.revoke(note.id, recipientIdentity); ``` Managed sync is required. A read grant permits reads, an edit grant permits reads and updates, and only the owner can delete or manage grants. Invitation delivery and user discovery remain app concerns. --- # Group-owned resource example Define a group, its membership relation, and resources that reference it: ```ts import { schema as s } from "jazz-tools"; import { groupMembershipTable } from "@nzip/lofi/access"; export const app = s.defineApp({ workspaces: s.table({ name: s.string() }), workspaceMembers: groupMembershipTable("workspaces"), documents: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }), }); ``` ```ts import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [groupAccess({ groups: app.workspaces, members: app.workspaceMembers, resources: app.documents, groupId: "workspaceId", })]); ``` ```ts import { createGroupOperations } from "@nzip/lofi/access"; import { app } from "./schema.ts"; const groups = createGroupOperations({ groups: app.workspaces, members: app.workspaceMembers, }); const { group } = await groups.createGroup({ name: "Release team" }); await groups.addMember(group.id, recipientIdentity, "contributor"); await groups.changeRole(group.id, recipientIdentity, "writer"); await groups.removeMember(group.id, recipientIdentity); ``` Readers can read. Contributors can create and edit their own rows. Writers can edit any group row. Admins can do everything writers can and manage membership. `leaveGroup` removes the current principal's membership. Managed sync is required for every group operation. The group's creator additionally keeps a permanent superseat: demoting or removing them does not end their ability to manage the group — see [Permission templates](../permissions.md#fixed-group-roles) before building handover flows. --- # Collaborative list recipe This recipe composes ordinary typed queries and mutations with the existing Group policy. There is no realtime permission mode and no group-specific query hook. ## Schema ```ts import { schema as s } from "jazz-tools"; import { groupMembershipTable } from "@nzip/lofi/access"; export const app = s.defineApp({ workspaces: s.table({ name: s.string() }), workspaceMembers: groupMembershipTable("workspaces"), records: s.table({ workspaceId: s.ref("workspaces"), title: s.string(), archived: s.boolean(), createdAt: s.timestamp(), }), }); ``` ## Access policy ```ts import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [groupAccess({ groups: app.workspaces, members: app.workspaceMembers, resources: app.records, groupId: "workspaceId", })]); ``` Readers see authorized rows. Contributors create and edit their own rows. Writers edit any group row. Admins also manage membership. Revoking membership removes rows from active queries when the updated authorization reaches the client; a rejected mutation appears through the mutation hook's `error` state. ## Scoped resources and membership ```ts import { useLiveQuery, useTableMutations } from "@nzip/lofi/preact"; import { app } from "../app.ts"; export function useWorkspaceRecords(workspaceId: string) { const records = useLiveQuery( () => app.schema.records .where({ workspaceId, archived: false }) .orderBy("createdAt", "desc"), [workspaceId], ); const members = useLiveQuery( () => app.schema.workspaceMembers.where({ groupId: workspaceId }), [workspaceId], ); const mutations = useTableMutations(app.schema.records); return { records, members, mutation: { pending: mutations.pending, durability: mutations.durability, error: mutations.error, }, create: (title: string) => mutations.insert({ workspaceId, title, archived: false, createdAt: new Date(), }), rename: (id: string, title: string) => mutations.update(id, { title }), archive: (id: string) => mutations.update(id, { archived: true }), remove: mutations.remove, }; } ``` The same composition works for direct sharing. Query the resource table for the collaborative list, query the conventional grant table for grant-management UI, and use `createSharingOperations` for share/revoke actions. Access policy—not the hook—filters each identity's rows. ## UI ```tsx export function RecordList({ workspaceId }: { workspaceId: string }) { const model = useWorkspaceRecords(workspaceId); if (model.records.status === "loading") return

Loading records…

; if (model.records.status === "error") return

{model.records.error}

; return (

{model.records.rows.length} records · {model.mutation.durability}

{model.mutation.error &&

{model.mutation.error}

}
    {model.records.rows.map((record) =>
  • {record.title}
  • )}
); } ``` Test at least two isolated identities: authorized insert/update/delete visibility, membership or grant revocation, rejected post-revocation mutations, local durability while offline, and convergence after reconnect. Use Lofi's two-client fixture for browser behavior and the official Jazz policy harness for deterministic permission transitions. --- # Decision records Framework changes that alter a documented product promise should retain a short decision record in this directory. Historical M1/M2 decisions predate this index and remain represented by the developer-experience contract, spike evidence, issues, and pull requests. Name new records `NNNN-short-title.md` and include: 1. **Question** — the decision being made. 2. **Contract IDs** — promises affected in `docs/devx-contract.md`. 3. **Exact versions** — package, runtime, browser, OS, and commit pins. 4. **Control** — the comparison or vendor baseline. 5. **Procedure** — repeatable commands and observable steps. 6. **Evidence** — retained, non-secret artifacts. 7. **Decision** — adopt, revise, defer, or reject. 8. **Contract delta** — what changed in the product promise. 9. **Follow-up** — remaining work with an owner or issue. A negative result is a valid decision. Do not replace evidence with a search summary, and never record environment values, account secrets, recovery phrases, or sensitive user content. --- # Group-creator authority under Jazz alpha.53 policy negation limits Date: 2026-07-17\ Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\ Decision: document permanent creator authority; grant direct creator delete; revisit on upgrade ## Problem The `groupAccess` template gives a group's creator `$createdBy` update authority on the group row so they can insert the first admin membership (membership management derives from group-update via `allowedTo.update("groupId")`). That authority never lapses: a creator who is later demoted or removed by other admins can restore their own admin membership at any time. Separately, a failed first-admin insert used to strand an undeletable orphan group, because rollback deletion required the admin membership that had just failed to insert. The intended fix was a **bootstrap window**: creator authority over the group row holds only while the group has no admin membership. That requires a negated existence condition — "no admin membership row exists for this group". ## Exact alpha.53 engine behavior The policy IR (`schema.js` `PolicyExpr`) declares `{ type: "Not", expr }`, the permissions compiler passes raw IR through, and `schema-permissions.js` serializes `Not` for the wasm core. The core, however, **silently drops `Not` around existence conditions**. Probed against `createPolicyTestApp` with a two-table group/members app, rule under test on `allowDelete`: | Probe | Expected | alpha.53 result | | ------------------------------------------------- | ---------- | ------------------------------- | | `Not(True)` / `Not(False)` | deny/allow | correct | | `Not(Cmp column = literal)` | deny | correct | | `Exists(...)` / `ExistsRel(...)` (pos + neg ctrl) | — | correct | | `Not(Exists(...))`, no matching rows | allow | **denied** | | `Not(ExistsRel(...))`, matching row present | deny | **allowed** | | `And(True, Not(ExistsRel(...)))`, row present | deny | **allowed** | | LEFT-join + `IsNull` anti-join via `ExistsRel` | allow | denied (join not null-extended) | `Not` over `Exists`/`ExistsRel` evaluates as if the `Not` were absent — a silent wrong-direction result, not an error. The `RelExpr` LEFT-join anti-join alternative does not produce null-extended rows in policy evaluation, so absence-of-rows is not expressible at all in this engine. ## Decision - **Creator authority over the group row stays permanent and becomes a documented trust property** of the template: the creator can always update the group and therefore always restore their own admin membership. Products that need creator-proof handover cannot get it from this template on this engine. - **The creator additionally receives direct `allowDelete` on their own group rows.** This grants no new authority — a creator can already self-bootstrap an admin membership and delete transitively — and it lets `createGroup` roll back cleanly when the first-admin insert fails, removing the orphan-group failure mode. - **An engine canary test** (`access_security_test.ts`, "engine canary") asserts the broken behavior: a `Not(ExistsRel(...))` delete guard that a correct engine would deny. When a jazz-tools upgrade fixes negation, the canary fails, signalling that the bootstrap-window design in this record should replace permanent creator authority: `allowUpdate/allowDelete = anyOf([isAdmin, allOf([$createdBy, Not(Exists(admin membership))])])`, which also preserves creator recovery of admin-less groups. ## Trust and fallback - The template's authority model is: fixed roles for members, plus a permanent creator superseat. The sharing UI and docs state this; it must not be presented as revocable admin membership. - Upstream: the dropped-negation behavior is a jazz-tools core issue worth reporting; until then, no lofi policy may rely on `Not` around `Exists`/`ExistsRel`. ## Evidence sources - Probe suite output recorded in this record's table (2026-07-17, Deno harness `createPolicyTestApp`, pinned npm cache for `jazz-tools@2.0.0-alpha.53`). - `dist/permissions/index.js` (raw IR pass-through), `dist/schema-permissions.js` (`Not` serialization), `dist/ir.d.ts` (`PolicyExprV2.Not`, `RelExpr`). --- # Jazz `BrowserPasskeyBackup` security review Date: 2026-07-17\ Reviewed dependency: `jazz-tools@2.0.0-alpha.53`\ Decision: retain the pin for Wave 2 ## Contract reviewed The current Jazz local-first-auth documentation describes `jazz-tools/passkey-backup` as a browser-only, encrypted-at-rest vault for the same 32-byte local-first secret used by `BrowserAuthSecretStore`. Restore must return that secret after a user-verifying WebAuthn ceremony; the live Jazz client must then be replaced. The recovery phrase remains the portable bearer-secret fallback. The exact installed implementation was reviewed at `jazz-tools/2.0.0-alpha.53/dist/runtime/passkey-backup.js` and its packaged tests, not inferred from a newer release. | Assumption | Exact alpha.53 behavior | Lofi boundary | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | At-rest protection | The 32-byte secret is supplied as the resident credential user handle. It is released by `credentials.get()` after the authenticator assertion; Jazz does not add application-level wrapping. | We rely on the browser/platform passkey provider to protect credential material at rest. We do not claim lofi controls or can audit provider encryption. | | RP-ID | `appHostname` becomes both `rp.id` during backup and `rpId` during restore. | Production apps should pin one canonical hostname. A changed or preview hostname cannot see the old passkey and is reported as an RP-ID mismatch when the browser exposes that distinction. | | Resident credential | `residentKey: "required"` and `requireResidentKey: true`. Restore uses discoverable lookup without an allow-list. | A non-resident authenticator does not satisfy the contract. | | User verification | Backup requests `userVerification: "required"` and `credentialProtectionPolicy: "userVerificationRequired"`. Restore again requires verification and checks both UP and UV bits before reading `userHandle`. | Cancellation and failed verification are non-secret, retryable UI errors. Lofi never treats credential presence alone as authority. | | Provider sync | The implementation requests `authenticatorAttachment: "platform"`; whether the passkey roams is decided by the OS/browser/provider. | Same-provider recovery is expected where that provider syncs passkeys. Universal iOS/Android/browser/provider portability is not promised. Cross-device QR flows may require the original device and are not loss recovery. | ## Decision Alpha.53 implements the documented resident, RP-scoped, user-verifying secret round trip and its packaged tests cover secret length, credential options, RP-ID, UP/UV enforcement, invalid handles, and round-trip restore. No dependency upgrade is required for the reviewed contract. Lofi exposes this as a **recoverable account backup**, distinct from its earlier **guard-only credential**. The earlier credential proves user presence before showing a locally stored recovery phrase; it does not contain the Jazz secret and cannot restore an account. Existing guard-only credentials remain usable for phrase reveal but are labeled as legacy/local and never presented as account backups. ## Trust and fallback - The passkey provider and browser are trusted to protect and, where supported, sync the resident credential. - The canonical RP-ID is part of the recovery boundary and must remain stable. - The recovery phrase is the portable fallback. It is the account secret encoded as words; anyone holding it can act as the account. - Restoring a different secret requires explicit confirmation while the current account is local-only, because unsynced local rows cannot be recovered from Jazz sync. - Principal replacement detaches lofi stores/subscriptions and shuts down the old Jazz client and workers via `db.shutdown()` — deliberately not `logout()`, which would clear local account state that must carry forward. It then saves the restored secret for the app ID, commits the sync election and managed-namespace record only after that save succeeds (a failed replacement leaves the previous local account bootable), reopens the runtime on the restored principal, and exposes the restored `session.user_id` for verification. ## Evidence sources - Jazz, “Local-first auth”: - Jazz, “Lifecycle”: - Pinned package source and tests in the resolved Deno npm cache for `jazz-tools@2.0.0-alpha.53` --- # Troubleshooting Start with: ```sh deno task doctor ``` The report names the blocked capability or configuration and gives a remediation without printing environment values. ## Doctor or build reports a PWA source blocker Start with the first named file and action. The checks validate `public/manifest.webmanifest` as JSON; require stable identity, names, same-origin launch scope, display mode, colors, and complete icon roles; and inspect the dimensions and MIME types of local icons, shortcuts, and optional screenshots. Asset filenames and branding are replaceable, and unknown optional manifest members remain allowed. If source checks pass but build reports `dist/`, do not hand-edit the output. Fix the author-owned manifest, shell link, route, or asset, then rerun `deno task build`. The production check deliberately treats HTML manifest links, the worker revision and scope, `lofi-build.json`, and `lofi-precache.json` as one artifact so a partial or stale build cannot pass. ## The app says configuration is incomplete Managed sync requires both public values: ```text JAZZ_APP_ID JAZZ_SERVER_URL ``` Set both, or remove both to run local-only. Do not move `JAZZ_ADMIN_SECRET` or `BACKEND_SECRET` into client-visible variables. If provisioning should replace an existing `.env`, review and back up the intended configuration, then use the command's explicit `--force` option: ```sh deno task jazz:provision --force ``` ## Durable storage is blocked lofi requires a secure context, OPFS, SharedWorker, Web Locks, and MessageChannel. It stops instead of silently switching to memory-only data. - Use a supported browser: Android Chrome 148+ or iOS Safari 16.4+. - Use `localhost` during development or HTTPS on another device. - Avoid embedded/private browser surfaces that disable required storage APIs. - Treat clearing site data as destructive unless the account has synced and the recovery phrase is safe. ## Another tab is running an incompatible app version Lofi stops persistent runtime startup when another tab for the same app is using an incompatible browser broker configuration. This is reported as `broker-incompatible` in runtime diagnostics in both local and managed mode. 1. Close every other tab or installed-app window for this app. 2. Return to the tab showing the recovery notice. 3. Select **Reload app** once. The package does not take over the existing broker, fall back to memory, or automatically enter a reload loop. Do not clear site data for this condition: closing the incompatible tabs and explicitly reloading creates the clean document boundary the persistent driver requires. ## A task disappeared after changing app configuration Check whether `databaseName` or the managed `JAZZ_APP_ID` changed. Both participate in the durable storage namespace. Restoring the old values reopens the old namespace; copying rows between namespaces is not automatic. ## Preview says the build is missing or stale Run: ```sh deno task build deno task preview ``` Do not hand-create `dist/lofi-build.json`; it must describe the output produced by the build. ## The production build reports a secret leak Remove the named server-only value from source, public files, logs, fixtures, and generated client configuration. Rotate a real credential if it entered source control or an artifact, then rebuild. ## Browser tests skip or cannot launch Set the base URL when running the opt-in example: ```sh LOFI_E2E_BASE_URL=http://127.0.0.1:4321/ \ deno test -A tests/convergence_e2e_test.ts ``` Install the pinned Chromium runtime if needed: ```sh deno run -A npm:playwright@1.61.1 install chromium ``` ## Local writes work but another device does not update Check these states separately: 1. The deployment has a complete public Jazz pair. 2. The user explicitly elected to sync in `AccountGate`. 3. The latest write reached global durability rather than only local durability. 4. Both devices restored the same account identity. 5. The deployed schema and permissions allow the operation. “Sync configured” is not proof that a specific user opted in or that a specific write reached the server. ## Recovery does not restore a recent item The phrase restores account authority, not unsynced device storage. Data returns only if it reached managed sync before the original device was lost or cleared. ## Passkey restore was cancelled or cannot find an account - **Cancelled:** retry the user-verification prompt, or continue with the recovery phrase. - **No recoverable passkey:** confirm the passkey exists in the active provider. A legacy phrase-reveal guard is not an account backup. - **Different app hostname / RP-ID:** open the canonical production hostname used during backup. - **Verification failed:** unlock the platform/password-manager authenticator and retry. - **Unsupported browser/provider:** use the recovery phrase. Provider availability is not portable across every iOS, Android, browser, and password-manager combination. The package maps these states without printing credential IDs, account secrets, or phrases. ## Sharing says managed sync is required Private resources work local-only. Direct shares and groups require both a configured Jazz server and the current account's explicit sync election. Back up and enable sync in `AccountGate`, then retry. A configured deployment alone does not opt the current account into sync. --- # API reference Generated from the JSDoc of [`@nzip/lofi`](https://jsr.io/@nzip/lofi) v0.4.0. A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, and private, direct-share, or group access templates. | Export | Summary | | --- | --- | | [`@nzip/lofi`](./runtime.md) | Build local-first browser applications with durable storage, reactive tables, optional Jazz sync, recoverable accounts, and an installable PWA lifecycle. | | [`@nzip/lofi/astro`](./astro.md) | Astro and Vite integration used by generated lofi applications. | | [`@nzip/lofi/access`](./access.md) | Narrow private, direct-share, and fixed-role group access templates over raw Jazz schemas. | | [`@nzip/lofi/preact`](./preact.md) | Optional Preact bindings for lofi device capabilities and PWA controls. | | [`@nzip/lofi/testing`](./testing.md) | The `@nzip/lofi/testing` public surface: Playwright-backed helpers for testing local-first behavior, including two-client fixtures, concurrent offline convergence, app-owned readiness waits, value-free failure artifacts, and a CDP virtual authenticator for headless WebAuthn flows. | | [`@nzip/lofi/create`](./cli.md#create) | The `deno run -A jsr:@nzip/lofi/create ` command: scaffolds a new local-first PWA project from the validated starter template. Pass `--sync` to also provision a managed Jazz app and write it into the project's `.env`, so the app can offer synced, backed-up accounts from the first boot. | | [`@nzip/lofi/build`](./cli.md#build) | The `deno task build` command: builds the static PWA into `dist/` with a source-hash build id, a service-worker precache manifest, and a scan that fails the build if server secrets leak into client output. | | [`@nzip/lofi/dev`](./cli.md#dev) | The `deno task dev` command: runs the Astro development server after lofi preflight checks and diagnostics. | | [`@nzip/lofi/doctor`](./cli.md#doctor) | The `deno task doctor` command: prints a value-free readiness report covering the package, Deno, project layout, environment, storage, identity, sync, and PWA, and exits non-zero when the project is blocked. | | [`@nzip/lofi/preview`](./cli.md#preview) | The `deno task preview` command: serves the built `dist/` output locally over HTTP for previewing the production PWA. | | [`@nzip/lofi/provision`](./cli.md#provision) | The `deno task jazz:provision` command: generates a managed Jazz app and writes its configuration into `.env`, so the project can offer synced, backed-up accounts. Prints the new app id and how to claim it — never the secrets, which live only in the git-ignored `.env`. | | [`@nzip/lofi/test`](./cli.md#test) | The `deno task test` command: runs the generated project's local-first test suite, retaining browser artifacts under `test-results/`. | | [`@nzip/lofi/recipes/file-handler`](./recipes/file-handler.md) | Opt-in helpers for turning file launches or ordinary picker files into bounded, validated import drafts. | | [`@nzip/lofi/recipes/launch-handler`](./recipes/launch-handler.md) | Opt-in helpers for installed-app launch handling and safe client reuse. | | [`@nzip/lofi/recipes/protocol-handler`](./recipes/protocol-handler.md) | Opt-in parser for a narrow custom-protocol collaborative-list deep link. | | [`@nzip/lofi/recipes/related-app-discovery`](./recipes/related-app-discovery.md) | Opt-in presentation-only discovery for verified related applications. | | [`@nzip/lofi/recipes/scope-extension`](./recipes/scope-extension.md) | Opt-in deployment helpers for reciprocal PWA scope extensions. | | [`@nzip/lofi/recipes/web-share`](./recipes/web-share.md) | Opt-in helpers for outbound Web Share and inbound text/link share targets. | | [`@nzip/lofi/recipes/window-controls-overlay`](./recipes/window-controls-overlay.md) | Opt-in geometry observation for desktop Window Controls Overlay layouts. | --- # Runtime ```ts import * as mod from "jsr:@nzip/lofi"; ``` Build local-first browser applications with durable storage, reactive tables, optional Jazz sync, recoverable accounts, and an installable PWA lifecycle. Application code composes these APIs; storage, identity, sync, PWA, and lifecycle implementations remain versioned package code. Start with `defineLofiApp` and `bootLofi`. Use `LofiRuntime.store` to bind declared Jazz tables to application UI, and add account backup only after managed sync is configured. ## AccountReplacementError class ```ts class AccountReplacementError extends Error { constructor(); readonly name: string; readonly code: string; } ``` Raised when account recovery needs explicit acknowledgement of local replacement. ## acquireLiveQuery function ```ts function acquireLiveQuery(query: QueryBuilder): LiveQueryLease ``` Acquires the package-wide shared store for an arbitrary typed query. ## acquireTableMutations function ```ts function acquireTableMutations(table: TableProxy): TableMutationLease ``` Acquires the package-wide typed mutation surface for one table. ## applyPwaUpdate function ```ts function applyPwaUpdate(): boolean ``` Activates a waiting service worker; returns false when no update is ready. ## assertDurableBrowser function ```ts function assertDurableBrowser(): void ``` Throws unless the current browser can open lofi's persistent local driver. ## authenticateDeviceCredential function ```ts async function authenticateDeviceCredential(options: AuthenticateOptions): Promise ``` Authenticates with a device passkey (user-verifying). Pass `credentialId` to pin the ceremony to one enrolled credential; without it the request is discoverable and any resident credential for the RP ID can assert. ## AuthError class ```ts class AuthError extends Error { constructor(code: AuthError["code"], message?: string); readonly name: string; readonly code: "cancelled" | "origin-rejected" | "unsupported" | "prf-unavailable" | "credential-missing" | "credential-mismatch" | "unknown"; } ``` A precise, non-leaking failure reason for a credential operation. ## bootLofi function ```ts async function bootLofi(): Promise ``` Starts lofi's browser lifecycle once for the current document. Import the module that calls `defineLofiApp` first so the runtime can resolve the app definition. Safe to call more than once; later calls are no-ops. **Returns:** A promise that resolves once boot has been scheduled for this document. **Example** ```ts import { bootLofi } from "@nzip/lofi"; // Importing app.ts registers defineLofiApp before the runtime boots. import { app } from "../app.ts"; void app; void bootLofi(); ``` ## checkPwaUpdate function ```ts function checkPwaUpdate(): Promise ``` Runs the shared controller's bounded update check when registration is ready. Resolves true when a check ran or was already in flight; inspect `PwaState.update` for outcome. ## classifyCredentialOrigin function ```ts function classifyCredentialOrigin(url: URL, trustedOrigins: readonly string[]): CredentialOriginReport ``` Classifies whether `url` is a safe origin to enroll a device credential on, against the author's committed-stable hostnames (`app.ts` `credentialOrigins`, exact hostnames or `*.` suffix patterns). Guidance is platform-agnostic — the requirement is a stable HTTPS origin you control, however you serve it. ## confirmPhraseAccess function ```ts async function confirmPhraseAccess(): Promise ``` Runs the user-verifying passkey ceremony that must precede revealing the phrase on a guarded device. The ceremony is pinned to the enrolled credential, so no other passkey for the same RP ID can satisfy it (guards enrolled before pinning remain discoverable until re-enrolled). A no-op when the device is not guarded. Throws `cancelled` if the user dismisses the prompt — the phrase is then not revealed. ## createBackupPasskey function ```ts async function createBackupPasskey(label?: string): Promise ``` Enrolls a passkey to guard the recovery phrase on this device. Enrollment is a user-verifying ceremony, so it doubles as the confirmation for the reveal that immediately follows. Returns `true` when a guard was set; `false` (without throwing) when this browser or origin cannot enroll a passkey, so the caller can still show the phrase while telling the user it is unguarded. Re-throws a `cancelled` ceremony so the caller can abort. ## createPwaController function ```ts function createPwaController(dependencies: PwaControllerDependencies): PwaController ``` Creates an isolated PWA controller, primarily for custom integration and tests. ## createRecoverablePasskeyBackup function ```ts async function createRecoverablePasskeyBackup(displayName?: string): Promise ``` Stores the current 32-byte local-first secret inside Jazz's resident, user-verifying passkey backup. Unlike `createBackupPasskey`, this credential can restore the account and is not merely a local phrase-reveal guard. ## decryptAtRest function ```ts async function decryptAtRest(key: CryptoKey, blob: { iv: Uint8Array; ciphertext: Uint8Array }): Promise ``` Decrypts a blob produced by `encryptAtRest`. ## defineLofiApp function ```ts function defineLofiApp(config: LofiAppConfig): LofiAppConfig ``` Defines the application-facing values the versioned runtime needs. The returned value retains the exact schema type so author code can select tables without importing or editing framework internals. | Parameter | Description | | --- | --- | | `config` | The author-owned app configuration, including the raw Jazz schema. | **Returns:** The same configuration, typed to the exact schema for table selection. **Example** ```ts import { defineLofiApp } from "@nzip/lofi"; import { app as schema } from "./schema.ts"; export const app = defineLofiApp({ name: "my-app", databaseName: "my-app", schema, storage: "durable", sync: { adapter: "jazz" }, }); ``` ## deriveAtRestKey function ```ts async function deriveAtRestKey(prfSecret: Uint8Array, info: string, salt: Uint8Array): Promise ``` Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256. Bind unrelated data to different keys by varying `info`. ## derivePrfSecret function ```ts async function derivePrfSecret(salt: BufferSource, dependencies: AuthDependencies): Promise ``` Derives a credential-bound secret from the passkey via the WebAuthn PRF extension in one user-verifying `get()`. The same `salt` on the same authenticator yields the same 32-byte secret; it never leaves the device. Throws `prf-unavailable` if the client or authenticator does not return a PRF result — the secret is never faked. ## durableCapabilityReport function ```ts function durableCapabilityReport(): DurableCapabilityReport ``` Reads synchronous browser capabilities needed by the durable Jazz driver. ## DurableStorageUnsupportedError class ```ts class DurableStorageUnsupportedError extends Error { constructor(report: DurableCapabilityReport); name: string; readonly report: DurableCapabilityReport; } ``` Raised when the browser cannot provide the durable local-storage contract. ## enableSyncBackup function ```ts async function enableSyncBackup(): Promise ``` Elects to back up and sync this account: replication to the managed Jazz app turns on and the existing local data pushes up under the same identity. Pair it with `revealRecoveryPhrase` so the user has a way back in. No-op when no Jazz app is configured. ## encryptAtRest function ```ts async function encryptAtRest(key: CryptoKey, plaintext: Uint8Array): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> ``` Encrypts `plaintext` with an at-rest key, returning a self-contained blob. ## enrollDeviceCredential function ```ts async function enrollDeviceCredential(options: EnrollOptions): Promise ``` Enrolls a resident, user-verifying device passkey with the PRF extension enabled. Refuses unless the current origin is `stable` (pass `rpId` to test). The returned `portable` flag says whether the credential roams across devices; `label` names it in the user's password manager. Returns the opaque id. ## getAuthCapability function ```ts async function getAuthCapability(dependencies: AuthDependencies): Promise ``` Reports what the current device/browser/origin can do for credential auth. ## getPwaState function ```ts function getPwaState(): PwaState ``` Returns the shared controller's current state snapshot. ## getRuntime function ```ts function getRuntime(): Promise ``` Opens or reuses the one package runtime for the current browser document. ## getRuntimeDiagnostics function ```ts function getRuntimeDiagnostics(): RuntimeDiagnostics ``` Returns a value-only snapshot of runtime resource and durability counters. ## getRuntimePrincipal function ```ts function getRuntimePrincipal(): string | null ``` The stable Jazz principal currently opened by the package runtime. ## isAccountReplacementError function ```ts function isAccountReplacementError(error: unknown): error is AccountReplacementError ``` True when recovery requires explicit confirmation before replacing a local account. ## isAuthError function ```ts function isAuthError(error: unknown): error is AuthError ``` True when an error came from a passkey ceremony (enroll / confirm). ## isRecoverablePasskeyError function ```ts function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError ``` True when an error came from a recoverable passkey backup or restore ceremony. ## isRecoveryError function ```ts function isRecoveryError(error: unknown): error is RecoveryError ``` True when an error is a recovery-phrase problem the user can fix and retry. ## LiveQueryStore class ```ts class LiveQueryStore { constructor(query: QueryBuilder, environment: LiveQueryEnvironment, onIdle: () => void); getSnapshot: () => LiveQuerySnapshot; subscribe: (listener: Listener) => () => void; get consumerCount(): number; dispose(): void; } ``` A shared framework-neutral reactive store for one typed Jazz query. ## LofiConfigurationError class ```ts class LofiConfigurationError extends Error { readonly name: string; readonly code: string; } ``` Raised when package runtime startup cannot resolve valid author-owned app configuration. ## pwaController const ```ts const pwaController: PwaController; ``` Shared controller used by the root runtime and optional Preact bindings. ## pwaFailureMessage function ```ts function pwaFailureMessage(code: PwaFailureCode): string ``` Returns actionable, non-technical recovery guidance for a PWA failure. ## readAccountSession function ```ts async function readAccountSession(): Promise ``` Resolves the runtime before returning a session with a stable `user_id`. ## readDeviceCapabilityReport function ```ts async function readDeviceCapabilityReport(): Promise ``` Reads the complete capability report without requesting new browser permission. ## readSession function ```ts function readSession(): Session ``` Reads the current session. Synchronous — it never prompts or touches the network. ## RecoverablePasskeyError class ```ts class RecoverablePasskeyError extends Error { constructor(code: RecoverablePasskeyErrorCode, options?: ErrorOptions); readonly name: string; } ``` A non-secret, actionable recoverable-passkey failure. ## RecoveryError class ```ts class RecoveryError extends Error { constructor(code: RecoveryError["code"], message?: string); readonly name: string; readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret"; } ``` A precise, non-leaking failure reason for a recovery-phrase operation. ## recreateRuntime function ```ts function recreateRuntime(): Promise ``` Replaces the active Jazz client while preserving the configured account secret. ## reloadAfterRuntimeStartupFailure function ```ts function reloadAfterRuntimeStartupFailure(reload: () => void): void ``` Performs the explicit navigation required after closing incompatible app tabs. ## reloadBrowserRuntime function ```ts async function reloadBrowserRuntime(): Promise ``` Fully tears down a browser persistent worker and reloads the document. Jazz alpha.53 cannot attach a second OPFS worker reliably in the same document after shutdown; navigation is the supported clean-runtime boundary. ## requestPersistentStorage function ```ts async function requestPersistentStorage(): Promise ``` Requests eviction protection, then returns the browser's authoritative capability report. ## requestPwaInstall function ```ts function requestPwaInstall(): Promise ``` Requests the deferred browser installation prompt when one is available. ## restoreFromPasskey function ```ts async function restoreFromPasskey(options: AccountReplacementOptions): Promise ``` Restores a passkey-backed secret and recreates Jazz on that stable principal. ## restoreFromRecoveryPhrase function ```ts async function restoreFromRecoveryPhrase(phrase: string, options: AccountReplacementOptions): Promise ``` Reconstructs an account from its recovery phrase, elects sync, and replaces the active runtime. Throws `RecoveryError` for malformed phrases and `AccountReplacementError` when confirmation is required. ## revealRecoveryPhrase function ```ts async function revealRecoveryPhrase(): Promise ``` Reveals the current account's recovery phrase — the same 32-byte secret encoded as words. Show it for the user to write down; never persist it. Works whether or not sync is on, but only matters once the account syncs, since the phrase recovers what has been backed up. ## runtimeRecreatedEvent const ```ts const runtimeRecreatedEvent: "lofi:runtime-recreated"; ``` Event dispatched after account, sync, or runtime replacement changes active state. ## RuntimeStartupError class ```ts class RuntimeStartupError extends Error { constructor(failure: RuntimeStartupFailure, cause?: unknown); readonly name: string; readonly code: RuntimeStartupFailureCode; readonly failure: RuntimeStartupFailure; } ``` Lofi-owned error boundary for a rejected persistent runtime startup. ## settleUiMutation function ```ts async function settleUiMutation(mutation: Promise): Promise ``` Lets event handlers await a UI mutation without leaking an unhandled rejection. ## shutdownRuntime function ```ts function shutdownRuntime(): Promise ``` Releases stores, subscriptions, the Jazz client, and persistent worker resources. ## stopSyncBackup function ```ts async function stopSyncBackup(): Promise ``` Stops replicating this account to the server and returns to local-only. The local data and the account are untouched — this only detaches the network, so electing to sync again resumes against the same account. ## subscribePwaState function ```ts function subscribePwaState(subscriber: (state: PwaState) => void): () => void ``` Subscribes to shared PWA state and returns an idempotent unsubscribe function. ## subscribeRuntimeDiagnostics function ```ts function subscribeRuntimeDiagnostics(listener: () => void): () => void ``` Subscribes to diagnostics changes and returns an idempotent unsubscribe function. ## TableMutationStore class ```ts class TableMutationStore { constructor(table: TableProxy, environment: TableMutationEnvironment, onIdle: () => void); getSnapshot: () => TableMutationSnapshot; subscribe: (listener: Listener) => () => void; get consumerCount(): number; insert(values: Init): Promise; update(id: string, patch: Partial): Promise; remove(id: string): Promise; dispose(): void; } ``` Framework-neutral typed mutations and observable durability for one table. ## TableStore class ```ts class TableStore { constructor(db: Db, table: TableHandle, diagnostics: RuntimeDiagnostics, options: TableStoreOptions); getSnapshot: () => TableSnapshot; subscribe: (listener: Listener) => () => void; insert(values: Init): Promise; update(id: string, patch: Partial): Promise; delete(id: string): Promise; reportMutationError(event: MutationErrorEvent): void; close(): void; } ``` A generic, reactive store over a single declared table. It has no knowledge of the application schema beyond `TableRow`: callers bind it to one of their own `schema.` tables and read/write typed rows through it. ## AccountReplacementOptions type ```ts type AccountReplacementOptions = { confirmLocalReplacement?: boolean; }; ``` Confirmation required before recovery may replace a different local-only account. ## AuthCapability type ```ts type AuthCapability = { webAuthn: boolean; prf: PrfSupport; origin: CredentialOriginReport; }; ``` What the current device/browser/origin can do for credential auth. ## AuthDependencies type ```ts type AuthDependencies = { credentials?: CredentialsContainer; rpId?: string; trustedOrigins?: readonly string[]; }; ``` Injected browser surfaces, so the flows are unit-testable without a device. ## CredentialOriginReport type ```ts type CredentialOriginReport = { status: "stable" | "local-only" | "unverified" | "blocked"; rpId: string; action: string; }; ``` How stable the current origin is for enrolling a passkey. A passkey is bound to the origin hostname (its RP ID); enrolling on an origin that later changes silently breaks the credential, so enrollment is gated on `stable`. ## DeviceCapabilityReport type ```ts type DeviceCapabilityReport = { secureContext: boolean; opfs: boolean; sharedWorker: boolean; webLocks: boolean; messageChannel: boolean; durableDriverSupported: boolean; webAuthn: boolean; prf: "available" | "not-reported" | "unknown" | "unavailable"; persistentPermission: "granted" | "not-granted" | "unavailable" | "error"; displayMode: "standalone" | "browser"; }; ``` Browser capabilities that determine whether lofi can provide its runtime guarantees. ## DeviceCredential type ```ts type DeviceCredential = { id: string; rpId: string; portable: boolean; }; ``` An enrolled or authenticated device credential. ## DurableCapabilityReport type ```ts type DurableCapabilityReport = Omit; ``` Capability report that excludes the separately requested persistence permission. ## EnrollOptions type ```ts type EnrollOptions = AuthDependencies & { label?: string }; ``` Options for `enrollDeviceCredential`. ## InstallEnvironment type ```ts type InstallEnvironment = { displayModeStandalone: boolean; navigatorStandalone: boolean; platform: string; maxTouchPoints: number; secureContext: boolean; serviceWorkerSupported: boolean; }; ``` Browser signals used to classify installed and manual-install experiences. ## InstallPromptEvent type ```ts type InstallPromptEvent = Event & { userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; prompt(): Promise }; ``` Chromium install event retained until application UI requests the prompt. ## LiveQueryLease type ```ts type LiveQueryLease = { store: LiveQueryStore; release(): void; }; ``` A retained reference to a shared live-query store. Release it after its consumer unsubscribes. ## LiveQuerySnapshot type ```ts type LiveQuerySnapshot = { status: "loading" | "ready" | "error"; rows: T[]; error: string | null; }; ``` Honest read state for an arbitrary typed Jazz query. ## LofiAppConfig type ```ts type LofiAppConfig = { name: string; databaseName: string; schema: Schema; storage: "durable"; credentialOrigins?: readonly string[]; passkey?: { rpId?: string }; sync: { adapter: "jazz" }; repositoryUrl?: string; }; ``` Author-owned configuration consumed by the package runtime. **Example** ```ts const config: LofiAppConfig = { name: "my-app", databaseName: "my-app", schema, storage: "durable", sync: { adapter: "jazz" }, }; ``` ## LofiRuntime type ```ts type LofiRuntime = { db: Db; diagnostics: RuntimeDiagnostics; store(table: TableHandle): TableStore; shutdown(): Promise; }; ``` The shared, lazily opened Jazz client and its application-facing adapters. ## PasskeyBackupReceipt type ```ts type PasskeyBackupReceipt = { user_id: string; rpId: string; }; ``` Non-secret confirmation that a recoverable passkey was created for an account and RP-ID. ## PrfSupport type ```ts type PrfSupport = | "available" | "not-reported" | "unknown" | "unavailable"; ``` Whether the WebAuthn PRF extension can be used on this client. ## PwaController type ```ts type PwaController = { getState(): PwaState; subscribe(subscriber: (state: PwaState) => void): () => void; requestInstall(): Promise; checkForUpdate(): Promise; applyUpdate(): boolean; initialize(): void; }; ``` Stateful controller for browser installation and service-worker updates. ## PwaControllerDependencies type ```ts type PwaControllerDependencies = { readonly eventTarget?: () => EventTarget; readonly visibilityTarget?: () => EventTarget; readonly serviceWorker?: () => ServiceWorkerContainer | undefined; readonly installEnvironment?: () => InstallEnvironment; readonly isVisible?: () => boolean; readonly now?: () => number; readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown; readonly clearTimeout?: (handle: unknown) => void; readonly updateCheckIntervalMs?: number; readonly updateCheckTimeoutMs?: number; readonly production?: () => boolean; readonly deploymentBaseUrl?: () => string; readonly reload?: () => void; readonly exposeState?: (state: PwaState) => void; }; ``` Injectable browser surfaces used to test the PWA lifecycle deterministically. ## PwaFailureCode type ```ts type PwaFailureCode = | "registration" | "installation" | "install-prompt" | "update-check" | "precache" | "runtime-cache"; ``` Stable categories for recoverable offline/PWA failures. ## PwaInstallState type ```ts type PwaInstallState = | "installed" | "available" | "prompting" | "accepted" | "dismissed" | "manual-ios" | "manual-browser" | "unsupported"; ``` Browser installation states exposed to application UI. ## PwaState type ```ts type PwaState = { worker: PwaWorkerState; install: PwaInstallState; update: PwaUpdateState; failure?: { code: PwaFailureCode; message: string }; }; ``` Current install, service-worker, and offline-cache state. ## PwaUpdateState type ```ts type PwaUpdateState = | "idle" | "checking" | "installing" | "ready" | "applying" | "failed"; ``` Foreground update-check and waiting-worker states exposed to application UI. ## PwaWorkerState type ```ts type PwaWorkerState = | "development-disabled" | "unsupported" | "registering" | "ready" | "failed"; ``` Service-worker lifecycle states exposed to application UI. ## RecoverablePasskeyErrorCode type ```ts type RecoverablePasskeyErrorCode = | "cancelled" | "unsupported" | "credential-missing" | "rp-id-mismatch" | "verification-failed" | "invalid-credential" | "backup-failed" | "restore-failed"; ``` Stable categories for recoverable passkey backup and restore failures. ## RowOf type ```ts type RowOf = Table extends { readonly _rowType: infer Row } ? Row : never; ``` The row type of one declared schema table — `RowOf`. Lets author code derive row types from its schema without importing the vendor module, keeping UI islands on public package seams. ## RuntimeDiagnostics type ```ts type RuntimeDiagnostics = { storageState: "persistent-requested" | "persistent-driver-open" | "failed"; startupFailure: RuntimeStartupFailure | null; clientsCreated: number; activeClients: number; activeConsumers: number; activeVendorSubscriptions: number; totalVendorSubscriptions: number; activeMutationListeners: number; totalMutationListeners: number; unsubscribeCalls: number; localWaitCalls: number; pendingLocalWrites: number; pendingGlobalWrites: number; lastWriteDurability: "none" | "local" | "global" | "failed"; mutationErrors: number; }; ``` // Package-owned runtime diagnostics. Runtime-owned observability counters. These describe the framework's storage, subscription, and write machinery and never reference any application schema. ## RuntimeStartupFailure type ```ts type RuntimeStartupFailure = { code: RuntimeStartupFailureCode; runtimeMode: "local" | "managed"; message: string; }; ``` Non-sensitive runtime context retained for diagnostics and recovery UI. ## RuntimeStartupFailureCode type ```ts type RuntimeStartupFailureCode = | "broker-incompatible" | "configuration-error" | "storage-startup-failed" | "unsupported-capabilities"; ``` Stable categories for failures that prevent Lofi's persistent runtime from opening. ## Session type ```ts type Session = { user_id: string | null; syncAvailable: boolean; backedUp: boolean; syncing: boolean; phraseGuarded: boolean; passkeyRecoverable: boolean; }; ``` A snapshot of the account: what is possible and what the user has chosen. ## TableHandle type ```ts type TableHandle = TableProxy & QueryBuilder; ``` A declared schema table (`schema.`). It is both an insert/update/delete target and a query source, so the store accepts the intersection Jazz expects. `T` is the row type and `Init` the insert type (row minus server-owned fields). ## TableMutationLease type ```ts type TableMutationLease = { store: TableMutationStore; release(): void; }; ``` Retained ownership of one shared table-mutation store. ## TableMutationSnapshot type ```ts type TableMutationSnapshot = { pending: number; durability: "none" | "local" | "global" | "failed"; error: string | null; }; ``` Observable state shared by every mutation consumer for one table. ## TableRow type ```ts type TableRow = { id: string; }; ``` The minimum shape every persisted row exposes to the framework. ## TableSnapshot type ```ts type TableSnapshot = { status: "loading" | "ready" | "error"; rows: T[]; durability: "none" | "local" | "global" | "failed"; error: string | null; }; ``` Reactive table state including rows and the last observed durability tier. ## TableStoreOptions type ```ts type TableStoreOptions = { syncConfigured?: () => boolean; onDiagnosticsChange?: () => void; }; ``` Runtime options controlling durability waits and diagnostics notifications. --- # Astro integration ```ts import * as mod from "jsr:@nzip/lofi/astro"; ``` Astro and Vite integration used by generated lofi applications. `prepareLofiAstroConfig` materializes version-matched runtime aliases and Jazz configuration into the ignored `.lofi/` directory. ## accessFiles const ```ts const accessFiles: ("errors.ts" | "identity.ts" | "mod.ts" | "operations.ts" | "policies.ts" | "schema.ts")[]; ``` Access modules vendored into `.lofi/`; kept in lockstep by the manifest test. ## preactFiles const ```ts const preactFiles: ("DeviceStatus.tsx" | "live-data.ts" | "mod.ts" | "PwaActions.tsx" | "RuntimeRecovery.tsx" | "use-device-capabilities.ts")[]; ``` Preact modules vendored into `.lofi/`; kept in lockstep by the manifest test. ## prepareLofiAstroConfig function ```ts async function prepareLofiAstroConfig(options: LofiAstroOptions): Promise ``` Materializes the package-owned Astro/Jazz integration into an ignored tooling directory so Astro's Node-compatible config loader can consume it. ## recipeFiles const ```ts const recipeFiles: ("file-handler.ts" | "launch-handler.ts" | "protocol-handler.ts" | "related-app-discovery.ts" | "scope-extension.ts" | "web-share.ts" | "window-controls-overlay.ts")[]; ``` Recipe modules vendored into `.lofi/`; kept in lockstep by the manifest test. ## runtimeFiles const ```ts const runtimeFiles: ("app.ts" | "auth.ts" | "boot.ts" | "config.ts" | "device-capabilities.ts" | "diagnostics.ts" | "durability.ts" | "env.d.ts" | "foreground-recovery.ts" | "inspector.ts" | "lifecycle.ts" | "live-query-store.ts" | "mod.ts" | "namespace-state.ts" | "passkey-recovery.ts" | "probe.ts" | "pwa.ts" | "recovery.ts" | "resource-lifecycle.ts" | "runtime.ts" | "session.ts" | "startup-recovery.ts" | "table-mutations.ts" | "table-store.ts" | "transport-gate.ts" | "ui-mutation.ts")[]; ``` Runtime modules vendored into a project's `.lofi/` directory. Static because the published package cannot list directories over JSR; the manifest test fails when this list and `package/runtime/` drift apart. ## LofiAstroOptions type ```ts type LofiAstroOptions = { root?: string; schemaDir?: string; }; ``` Options for materializing the package-owned Astro configuration. --- # Access ```ts import * as mod from "jsr:@nzip/lofi/access"; ``` Narrow private, direct-share, and fixed-role group access templates over raw Jazz schemas. Use `privateAccess`, `sharedAccess`, or `groupAccess` to declare common authorization models. Runtime operations require managed sync for collaboration and throw `AccessError` when that precondition is not met. Raw Jazz policy callbacks remain available through `defineAccessPolicies`. ## AccessError class ```ts class AccessError extends Error { constructor(code: AccessErrorCode, message: string, options?: ErrorOptions); readonly name: string; } ``` Actionable failure raised by access templates and collaboration operations. ## createGroupOperations function ```ts function createGroupOperations(config: { groups: AccessRuntimeTable; members: AccessRuntimeTable }): GroupOperations ``` Creates fixed-role group membership operations for a declared table pair. ## createSharingOperations function ```ts function createSharingOperations(config: { resource: AccessRuntimeTable; grants: AccessRuntimeTable }): SharingOperations ``` Creates direct-share operations that wait for local and global durability. ## decodeSharingIdentity function ```ts function decodeSharingIdentity(identity: string): string ``` Validates an app-scoped sharing identity and returns its raw Jazz principal. Surrounding whitespace from copy/paste is tolerated; whitespace inside the principal is rejected — a grant for a padded principal would look active to the owner while never matching the recipient's actual account. ## defineAccessPolicies function ```ts function defineAccessPolicies(app: TApp, templates: readonly AccessTemplate[], raw?: RawAccessPolicyExtension): CompiledPermissions ``` Compiles the three narrow templates through Jazz's own policy builder. The optional callback is the raw Jazz-policy escape hatch for app-specific rules. | Parameter | Description | | --- | --- | | `app` | The raw Jazz app returned by `s.defineApp`. | | `templates` | At least one `privateAccess`, `sharedAccess`, or `groupAccess` template; every table needs a policy. | | `raw` | Optional raw-policy callback for app-specific rules beyond the templates. | **Returns:** Compiled permissions suitable as the app's default policy export. **Example** ```ts import { defineAccessPolicies, groupAccess, sharedAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [ sharedAccess({ resource: app.notes, grants: app.noteGrants }), groupAccess({ groups: app.workspaces, members: app.workspaceMembers, resources: app.documents, groupId: "workspaceId", }), ]); ``` ## encodeSharingIdentity function ```ts function encodeSharingIdentity(userId: string): SharingIdentity ``` Encodes a raw Jazz principal as a versioned identity scoped to the current app. ## groupAccess function ```ts function groupAccess(config: { groups: AccessTable; members: AccessTable; resources: AccessTable | readonly AccessTable[]; groupId: string }): GroupAccessTemplate ``` Declares fixed-role membership policy for one or more group-owned resources. The membership table must be declared with the `groupMembershipTable` helper (or match its column shape), and each resource table must carry the `groupId` column referencing the group table. | Parameter | Description | | --- | --- | | `config` | The group table, membership table, group-owned resource table(s), and the resource column that references the group. | **Returns:** A template for `defineAccessPolicies`. **Example** ```ts import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [groupAccess({ groups: app.workspaces, members: app.workspaceMembers, resources: app.documents, groupId: "workspaceId", })]); ``` ## groupMembershipTable function ```ts function groupMembershipTable(group: Group): DefinedTable<{ groupId: RefColumn; user_id: StringColumn; role: StringColumn; can_create: BooleanColumn; can_edit_any: BooleanColumn; can_manage: BooleanColumn }> ``` Creates the conventional relationship table used by `groupAccess`. ## groupRoleCapabilities function ```ts function groupRoleCapabilities(role: GroupRole): { readonly role: GroupRole; readonly can_create: boolean; readonly can_edit_any: boolean; readonly can_manage: boolean } ``` Returns the persisted capability flags for a fixed group role. ## groupRoles const ```ts const groupRoles: ("reader" | "contributor" | "writer" | "admin")[]; ``` Fixed Wave 2 group roles. Custom role systems remain a raw Jazz escape hatch. ## isAccessError function ```ts function isAccessError(error: unknown): error is AccessError ``` True when an error came from the lofi access surface. ## privateAccess function ```ts function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate ``` Declares owner-only read and mutation policy for one resource table. | Parameter | Description | | --- | --- | | `config` | The resource table whose rows are visible and mutable only to their creator. | **Returns:** A template for `defineAccessPolicies`. **Example** ```ts import { defineAccessPolicies, privateAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [privateAccess({ resource: app.notes })]); ``` ## sharedAccess function ```ts function sharedAccess(config: { resource: AccessTable; grants: AccessTable }): SharedAccessTemplate ``` Declares owner plus explicit read/edit grants for one resource table. The grant table must be declared with the `sharedGrantTable` helper (or match its column shape) and reference the resource table. | Parameter | Description | | --- | --- | | `config` | The shared resource table and its grant table. | **Returns:** A template for `defineAccessPolicies`. **Example** ```ts import { defineAccessPolicies, sharedAccess } from "@nzip/lofi/access"; import { app } from "./schema.ts"; export default defineAccessPolicies(app, [ sharedAccess({ resource: app.notes, grants: app.noteGrants }), ]); ``` ## sharedGrantTable function ```ts function sharedGrantTable(resource: Resource): DefinedTable<{ resourceId: RefColumn; user_id: StringColumn; can_edit: BooleanColumn }> ``` Creates the conventional relationship table used by `sharedAccess`. ## sharingIdentity function ```ts async function sharingIdentity(): Promise ``` Returns the non-secret, app-scoped identity users may copy for shares. ## AccessErrorCode type ```ts type AccessErrorCode = | "configuration" | "invalid-identity" | "sync-required" | "mutation-rejected" | "not-found" | "invalid-role"; ``` Stable categories for access configuration and collaboration failures. ## AccessRuntimeTable type ```ts type AccessRuntimeTable = TableProxy & { where(input: unknown): QueryBuilder }; ``` Jazz table shape required by the access operation helpers. ## AccessTable type ```ts type AccessTable = { readonly _table: string; readonly _schema: Record; }; ``` Minimum declared Jazz table metadata consumed by access templates. ## AccessTemplate type ```ts type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate; ``` Any built-in access policy template accepted by `defineAccessPolicies`. ## GroupAccessTemplate type ```ts type GroupAccessTemplate = { readonly kind: "group"; readonly groups: AccessTable; readonly members: AccessTable; readonly resources: readonly AccessTable[]; readonly groupId: string; }; ``` Fixed-role group, membership, and resource policy template. ## GroupMembershipRow type ```ts type GroupMembershipRow = Identified & { groupId: string; user_id: string; role: GroupRole; can_create: boolean; can_edit_any: boolean; can_manage: boolean }; ``` Conventional fixed-role group membership row. ## GroupOperations type ```ts type GroupOperations = { createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>; addMember(groupId: string, recipient: SharingIdentity | string, role: GroupRole): Promise; changeRole(groupId: string, recipient: SharingIdentity | string, role: GroupRole): Promise; removeMember(groupId: string, recipient: SharingIdentity | string): Promise; leaveGroup(groupId: string): Promise; listMembers(groupId: string): Promise; }; ``` Fixed-role group creation and membership operations. ## GroupRole type ```ts type GroupRole = (typeof groupRoles)[number]; ``` Fixed group roles supported by the built-in group policy template. ## Identified type ```ts type Identified = { id: string; }; ``` Minimum row shape accepted by collaboration operations. ## PrivateAccessTemplate type ```ts type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable; }; ``` Owner-only resource policy template. ## RawAccessPolicyContext type ```ts type RawAccessPolicyContext = { policy: Record; session: { user_id: unknown }; allowedTo: { update(fkColumn: string): unknown }; anyOf(conditions: readonly unknown[]): unknown; allOf(conditions: readonly unknown[]): unknown; }; ``` Raw Jazz policy-builder context exposed to advanced policy extensions. ## RawAccessPolicyExtension type ```ts type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void; ``` Callback for app-specific rules that do not fit the built-in templates. ## RuleBuilder type ```ts type RuleBuilder = { where(input: unknown): unknown; always(): unknown; }; ``` Minimal Jazz rule builder exposed to raw access-policy extensions. ## SharedAccessTemplate type ```ts type SharedAccessTemplate = { readonly kind: "shared"; readonly resource: AccessTable; readonly grants: AccessTable; }; ``` Direct-share resource and grant-table policy template. ## SharedGrantRow type ```ts type SharedGrantRow = Identified & { resourceId: string; user_id: string; can_edit: boolean }; ``` Conventional direct-share grant row. ## ShareLevel type ```ts type ShareLevel = "read" | "edit"; ``` Access level assigned by a direct share. ## SharingIdentity type ```ts type SharingIdentity = string & { readonly __lofiSharingIdentity: true }; ``` App-scoped, non-secret Jazz principal identifier safe to copy between users. ## SharingOperations type ```ts type SharingOperations = { share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise; revoke(resourceId: string, recipient: SharingIdentity | string): Promise; listShares(resourceId: string): Promise; sharedWithMe(): Promise; }; ``` Direct-share operations bound to one resource and grant table pair. ## TablePolicy type ```ts type TablePolicy = { allowRead: RuleBuilder; allowInsert: RuleBuilder; allowUpdate: RuleBuilder; allowDelete: RuleBuilder; exists: { where(input: unknown): unknown }; }; ``` Read and mutation policy builders for one declared table. --- # Preact ```ts import * as mod from "jsr:@nzip/lofi/preact"; ``` Optional Preact bindings for lofi device capabilities and PWA controls. These components are package-owned examples that application layouts may compose or replace with UI built on the same public runtime APIs. ## DeviceStatus function ```ts function DeviceStatus(): VNode ``` Renders live storage, sync, auth, and PWA capability diagnostics. **Returns:** The device diagnostics panel, grouped by owning subsystem. **Example** ```tsx import { DeviceStatus } from "@nzip/lofi/preact"; export function SettingsPage() { return ; } ``` ## PwaActions function ```ts function PwaActions({ ..., ... }: PwaActionsProps): VNode | null ``` A composable install/update surface that keeps browser event handling package-owned. | Parameter | Description | | --- | --- | | `props` | An optional controller override and heading text. | **Returns:** The install/update section, or `null` when no action or status is relevant. **Example** ```tsx import { PwaActions } from "@nzip/lofi/preact"; ; ``` ## pwaFailureMessage function ```ts function pwaFailureMessage(code: PwaFailureCode): string ``` Returns actionable, non-technical recovery guidance for a PWA failure. ## RuntimeRecovery function ```ts function RuntimeRecovery({ ..., ... }: RuntimeRecoveryProps): JSX.Element | null ``` Renders recovery only when another tab is running an incompatible broker version. | Parameter | Description | | --- | --- | | `props` | The startup failure to inspect and an optional reload override. | **Returns:** The recovery prompt, or `null` when no incompatible-broker failure is present. **Example** ```tsx import { RuntimeRecovery } from "@nzip/lofi/preact"; ; ``` ## useDeviceCapabilities function ```ts function useDeviceCapabilities(): DeviceCapabilitiesHook ``` Reads browser capabilities and exposes an explicit persistence request. ## useLiveQuery function ```ts function useLiveQuery(createQuery: () => QueryBuilder, dependencies: readonly unknown[]): LiveQuerySnapshot ``` Subscribes a Preact component to any typed Jazz query. Equivalent mounted queries share one Jazz subscription. The snapshot preserves the exact row type produced by the builder, including `select` and `include` projections. An empty `rows` array with `status: "ready"` is an empty result, not a loading signal. | Parameter | Description | | --- | --- | | `createQuery` | Builds the typed Jazz query; re-invoked when dependencies change. | | `dependencies` | Values that, when changed, release the query and open a replacement. | **Returns:** The live snapshot: `status`, exact typed `rows`, and `error`. **Example** ```ts import { useLiveQuery } from "@nzip/lofi/preact"; import { app } from "../app.ts"; const records = useLiveQuery( () => app.schema.records.where({ workspaceId, archived: false }), [workspaceId], ); // records.status is "loading" | "ready" | "error"; records.rows is typed. ``` ## usePwaState function ```ts function usePwaState(controller: PwaController): PwaState ``` Subscribes a Preact component to an isolated or shared PWA controller. | Parameter | Description | | --- | --- | | `controller` | The controller to observe; defaults to the package-wide PWA controller. | **Returns:** The current install/update state, kept live via subscription. **Example** ```tsx import { usePwaState } from "@nzip/lofi/preact"; const pwa = usePwaState(); const updateReady = pwa.update === "ready"; ``` ## useTableMutations function ```ts function useTableMutations(table: TableProxy): TableMutations ``` Binds one typed Jazz table to stable insert, update, and remove methods. Mutation promises resolve after local durability; when managed sync is active, global confirmation continues in the background and updates the shared table-scoped `pending`, `durability`, and `error` state. | Parameter | Description | | --- | --- | | `table` | The typed schema table to mutate, e.g. `app.schema.records`. | **Returns:** Stable `insert`, `update`, and `remove` methods plus the shared mutation state. **Example** ```ts import { useTableMutations } from "@nzip/lofi/preact"; import { app } from "../app.ts"; const records = useTableMutations(app.schema.records); const created = await records.insert({ title: "Release notes", archived: false }); await records.update(created.id, { archived: true }); await records.remove(created.id); ``` ## DeviceCapabilitiesHook type ```ts type DeviceCapabilitiesHook = { report: DeviceCapabilityReport | null; requestPersistence(): Promise; }; ``` State returned by `useDeviceCapabilities`. ## DeviceCapabilityReport type ```ts type DeviceCapabilityReport = { secureContext: boolean; opfs: boolean; sharedWorker: boolean; webLocks: boolean; messageChannel: boolean; durableDriverSupported: boolean; webAuthn: boolean; prf: "available" | "not-reported" | "unknown" | "unavailable"; persistentPermission: "granted" | "not-granted" | "unavailable" | "error"; displayMode: "standalone" | "browser"; }; ``` Browser capabilities that determine whether lofi can provide its runtime guarantees. ## LiveQuerySnapshot type ```ts type LiveQuerySnapshot = { status: "loading" | "ready" | "error"; rows: T[]; error: string | null; }; ``` Honest read state for an arbitrary typed Jazz query. ## PwaActionsProps interface ```ts interface PwaActionsProps { readonly controller?: PwaController; readonly title?: string; } ``` Optional controller and heading text for `PwaActions`. ## PwaController type ```ts type PwaController = { getState(): PwaState; subscribe(subscriber: (state: PwaState) => void): () => void; requestInstall(): Promise; checkForUpdate(): Promise; applyUpdate(): boolean; initialize(): void; }; ``` Stateful controller for browser installation and service-worker updates. ## PwaFailureCode type ```ts type PwaFailureCode = | "registration" | "installation" | "install-prompt" | "update-check" | "precache" | "runtime-cache"; ``` Stable categories for recoverable offline/PWA failures. ## PwaState type ```ts type PwaState = { worker: PwaWorkerState; install: PwaInstallState; update: PwaUpdateState; failure?: { code: PwaFailureCode; message: string }; }; ``` Current install, service-worker, and offline-cache state. ## PwaUpdateState type ```ts type PwaUpdateState = | "idle" | "checking" | "installing" | "ready" | "applying" | "failed"; ``` Foreground update-check and waiting-worker states exposed to application UI. ## RuntimeRecoveryProps type ```ts type RuntimeRecoveryProps = { failure: RuntimeStartupFailure | null; reload?: () => void; }; ``` Inputs for Lofi's explicit persistent-runtime recovery action. ## TableMutations type ```ts type TableMutations = TableMutationSnapshot & { insert(values: Init): Promise; update(id: string, patch: Partial): Promise; remove(id: string): Promise }; ``` Typed mutation methods plus their shared table-scoped observable state. ## TableMutationSnapshot type ```ts type TableMutationSnapshot = { pending: number; durability: "none" | "local" | "global" | "failed"; error: string | null; }; ``` Observable state shared by every mutation consumer for one table. --- # Testing ```ts import * as mod from "jsr:@nzip/lofi/testing"; ``` The `@nzip/lofi/testing` public surface: Playwright-backed helpers for testing local-first behavior, including two-client fixtures, concurrent offline convergence, app-owned readiness waits, value-free failure artifacts, and a CDP virtual authenticator for headless WebAuthn flows. ## BrowserTestClient class ```ts class BrowserTestClient { constructor(name: ClientName, context: BrowserContext, page: Page, baseURL: string, createContext: (state?: MemoryStorageState) => Promise, redact: (value: string) => string, traceOnFailure: boolean); get context(): BrowserContext; get page(): Page; get offline(): boolean; get diagnostics(): readonly BrowserDiagnostic[]; startRecording(): Promise; goOffline(): Promise; goOnline(): Promise; reloadPage(): Promise; restartPage(): Promise; restartClient(options: { preserveIdentity?: boolean }): Promise; captureTrace(path: string): Promise; close(): Promise; } ``` One browser client (context + page) whose identity and IndexedDB stay in memory. Records redacted diagnostics and can go offline/online, restart, and capture a sanitized trace. ## BrowserUnavailableError class ```ts class BrowserUnavailableError extends Error { constructor(options?: ErrorOptions); readonly name: string; } ``` Thrown when Playwright's Chromium browser is not installed, with install guidance. ## ConvergenceScenarioError class ```ts class ConvergenceScenarioError extends Error { constructor(stage: string, options?: ErrorOptions); readonly name: string; } ``` Thrown when a convergence scenario fails, naming the stage that failed via `stage`. ## createTwoClientFixture function ```ts async function createTwoClientFixture(options: TwoClientFixtureOptions): Promise ``` Launch (or reuse) a browser and build a `TwoClientFixture`: opens both clients at `baseURL`, applies the chosen identity mode with state kept in memory, and starts recording. Cleans up on any setup failure. | Parameter | Description | | --- | --- | | `options` | The base URL, identity mode, and optional browser, context, and artifact settings. | **Returns:** A ready fixture whose two clients are open at `baseURL` with identity applied. **Example** ```ts const fixture = await createTwoClientFixture({ baseURL, identity: { mode: "shared", preparePrimary: (client) => ready(client) }, artifacts: { directory: "test-results" }, }); try { await fixture.goOffline(); // ...apply edits on fixture.first and fixture.second, then converge... } finally { await fixture.close(); } ``` ## ReadinessError class ```ts class ReadinessError extends Error { constructor(description: string, options?: ErrorOptions); readonly name: string; } ``` Thrown when the readiness predicate does not become true before the timeout. ## runConcurrentOfflineConvergence function ```ts async function runConcurrentOfflineConvergence(fixture: OfflineTestFixture, scenario: ConcurrentOfflineScenario): Promise ``` Coordinates the transport lifecycle while the app owns edits, assertions and conflict semantics. Both offline edits are started in the same microtask. ## TwoClientFixture class ```ts class TwoClientFixture { constructor(browser: Browser, ownsBrowser: boolean, first: BrowserTestClient, second: BrowserTestClient, artifacts: FailureArtifactOptions | undefined); readonly clients: readonly [BrowserTestClient, BrowserTestClient]; get first(): BrowserTestClient; get second(): BrowserTestClient; goOffline(): Promise; goOnline(): Promise; captureFailure(label: string, snapshot?: (client: BrowserTestClient) => Promise): Promise; close(): Promise; } ``` Coordinates a pair of `BrowserTestClient`s sharing one browser, with helpers to take both clients offline/online and to capture redacted, value-free failure artifacts. ## waitForReady function ```ts async function waitForReady(page: Page, predicate: (argument: Argument) => boolean | Promise, argument: Argument, options: ReadinessOptions): Promise ``` Wait for an app-owned browser predicate. The predicate runs in the page and Playwright owns the timeout/polling, so tests do not need arbitrary sleeps. | Parameter | Description | | --- | --- | | `page` | The Playwright page to poll. | | `predicate` | An app-owned readiness check evaluated inside the page. | | `argument` | A serializable value passed to the predicate in the page. | | `options` | Optional description, timeout, and polling configuration. | **Returns:** Resolves when the predicate becomes true; rejects with `ReadinessError` on timeout. **Example** ```ts import { waitForReady } from "@nzip/lofi/testing"; await waitForReady(client.page, () => document.querySelector(".task-list") !== null, undefined, { description: "task list rendered", }); ``` ## withVirtualAuthenticator function ```ts async function withVirtualAuthenticator(page: Page, options: VirtualAuthenticatorOptions): Promise ``` Install a CDP virtual authenticator on `page` and return a handle that removes it on `VirtualAuthenticatorHandle.dispose`. Defaults model an internal, resident, user-verifying platform authenticator that auto-satisfies presence. ## BrowserDiagnostic interface ```ts interface BrowserDiagnostic { readonly client: ClientName; readonly kind: "console" | "page-error" | "request-failed"; readonly level?: string; readonly message: string; readonly url?: string; } ``` A redacted console, page-error, or failed-request record captured from a client. ## ClientName type ```ts type ClientName = "first" | "second"; ``` Identifies which of the fixture's two clients a value belongs to. ## ConcurrentOfflineScenario interface ```ts interface ConcurrentOfflineScenario { readonly edits: readonly [Edit, Edit]; readonly timeoutMs?: number; readonly ready: (client: Client, signal: AbortSignal) => Promise; readonly apply: (client: Client, edit: Edit, signal: AbortSignal) => Promise; readonly locallyApplied: (client: Client, edit: Edit, signal: AbortSignal) => Promise; readonly whilePending?: (fixture: OfflineTestFixture, signal: AbortSignal) => Promise; readonly converged: (fixture: OfflineTestFixture, signal: AbortSignal) => Promise; readonly snapshot?: (client: Client) => Promise; readonly failureLabel?: string; } ``` App-owned definition of a concurrent-offline convergence test: the two edits plus the hooks that assert readiness, apply and locally verify each edit, and confirm convergence after reconnection. ## FailureArtifactOptions interface ```ts interface FailureArtifactOptions { readonly directory: string; readonly secretValues?: readonly string[]; readonly mask?: (client: BrowserTestClient) => readonly Locator[]; } ``` Configures where and how redacted failure artifacts are written on capture. ## FailureArtifacts interface ```ts interface FailureArtifacts { readonly directory: string; readonly files: readonly string[]; } ``` The directory and file paths produced by a failure capture. ## IdentityOptions type ```ts type IdentityOptions = { readonly mode: "shared"; readonly preparePrimary: (client: BrowserTestClient) => Promise } | { readonly mode: "isolated"; readonly prepare?: (client: BrowserTestClient) => Promise }; ``` How the two clients obtain identity: `shared` clones the primary's prepared state (in memory) into the second client, `isolated` prepares each client on its own. ## OfflineTestClient interface ```ts interface OfflineTestClient { readonly offline: boolean; goOffline(): Promise; goOnline(): Promise; } ``` Minimal client contract the convergence runner needs: offline state and toggles. ## OfflineTestFixture interface ```ts interface OfflineTestFixture { readonly clients: readonly [Client, Client]; readonly first: Client; readonly second: Client; goOffline(): Promise; goOnline(): Promise; captureFailure(label: string, snapshot?: (client: Client) => Promise): Promise; } ``` Minimal two-client fixture contract required to drive an offline scenario. ## ReadinessOptions interface ```ts interface ReadinessOptions { description?: string; timeoutMs?: number; polling?: "raf" | number; } ``` Options controlling `waitForReady`'s description, timeout, and polling. ## SafeContextOptions type ```ts type SafeContextOptions = Omit & { storageState?: never }; ``` Playwright context options with `storageState` forbidden, so identity never leaves memory via an on-disk state file. ## TwoClientFixtureOptions interface ```ts interface TwoClientFixtureOptions { readonly baseURL: string; readonly identity: IdentityOptions; readonly browser?: Browser; readonly context?: SafeContextOptions; readonly artifacts?: FailureArtifactOptions; readonly traceOnFailure?: boolean; } ``` Options for constructing a `TwoClientFixture` via `createTwoClientFixture`. ## ValueFreeState type ```ts type ValueFreeState = | null | boolean | number | { [key: string]: ValueFreeState } | readonly ValueFreeState[]; ``` A JSON-like value restricted to booleans, finite numbers, arrays, and plain objects. Strings are excluded so state snapshots record shape and counts but never user values or credentials. ## VirtualAuthenticatorCredential interface ```ts interface VirtualAuthenticatorCredential { readonly credentialId: string; readonly isResidentCredential: boolean; readonly rpId?: string; readonly privateKey: string; readonly userHandle?: string; readonly signCount: number; readonly largeBlob?: string; } ``` Serializable CDP credential used to copy one virtual passkey between browser profiles. ## VirtualAuthenticatorHandle interface ```ts interface VirtualAuthenticatorHandle { readonly authenticatorId: string; credentials(): Promise; addCredential(credential: VirtualAuthenticatorCredential): Promise; clearCredentials(): Promise; dispose(): Promise; } ``` A handle to an installed virtual authenticator; call `dispose` to remove it. ## VirtualAuthenticatorOptions interface ```ts interface VirtualAuthenticatorOptions { readonly protocol?: "ctap2" | "u2f"; readonly transport?: "usb" | "nfc" | "ble" | "cable" | "internal"; readonly hasResidentKey?: boolean; readonly hasUserVerification?: boolean; readonly isUserVerified?: boolean; readonly automaticPresenceSimulation?: boolean; } ``` Options for `withVirtualAuthenticator`. --- # CLI commands Executable entrypoints run with `deno run` (the generated project wires them up as `deno task` commands). They expose behavior, not exported symbols. ## create ```sh deno run -A jsr:@nzip/lofi/create ``` The `deno run -A jsr:@nzip/lofi/create ` command: scaffolds a new local-first PWA project from the validated starter template. Pass `--sync` to also provision a managed Jazz app and write it into the project's `.env`, so the app can offer synced, backed-up accounts from the first boot. ## build ```sh deno run -A jsr:@nzip/lofi/build ``` The `deno task build` command: builds the static PWA into `dist/` with a source-hash build id, a service-worker precache manifest, and a scan that fails the build if server secrets leak into client output. ## dev ```sh deno run -A jsr:@nzip/lofi/dev ``` The `deno task dev` command: runs the Astro development server after lofi preflight checks and diagnostics. ## doctor ```sh deno run -A jsr:@nzip/lofi/doctor ``` The `deno task doctor` command: prints a value-free readiness report covering the package, Deno, project layout, environment, storage, identity, sync, and PWA, and exits non-zero when the project is blocked. ## preview ```sh deno run -A jsr:@nzip/lofi/preview ``` The `deno task preview` command: serves the built `dist/` output locally over HTTP for previewing the production PWA. ## provision ```sh deno run -A jsr:@nzip/lofi/provision ``` The `deno task jazz:provision` command: generates a managed Jazz app and writes its configuration into `.env`, so the project can offer synced, backed-up accounts. Prints the new app id and how to claim it — never the secrets, which live only in the git-ignored `.env`. ## test ```sh deno run -A jsr:@nzip/lofi/test ``` The `deno task test` command: runs the generated project's local-first test suite, retaining browser artifacts under `test-results/`. --- # File handler ```ts import * as mod from "jsr:@nzip/lofi/recipes/file-handler"; ``` Opt-in helpers for turning file launches or ordinary picker files into bounded, validated import drafts. ## installFileLaunchConsumer function ```ts function installFileLaunchConsumer(options: InstallFileLaunchConsumerOptions): InstalledLaunchConsumer ``` Feature-detect `launchQueue` and turn file launches into validated drafts. ## prepareFileImportDrafts function ```ts async function prepareFileImportDrafts(inputs: readonly ((File | InstalledAppFileHandle))[], options: PrepareFileImportOptions): Promise> ``` Validate picker files or installed-app handles and parse them into drafts. The function performs no writes. Keep the returned drafts in transient UI state, show a preview, and persist only after an explicit user confirmation. ## FileImportAccept type ```ts type FileImportAccept = Readonly>; ``` Explicit MIME-to-extension allow-list shared by the manifest and importer. ## FileImportDraft type ```ts type FileImportDraft = { readonly file: File; readonly name: string; readonly type: string; readonly size: number; readonly parsed: Parsed; }; ``` One parsed file ready for product-owned preview, but not persistence. ## FileImportIssue type ```ts type FileImportIssue = | "empty-selection" | "too-many-files" | "invalid-handle" | "unreadable-file" | "invalid-name" | "unsupported-type" | "unsupported-extension" | "file-too-large" | "selection-too-large" | "invalid-content"; ``` Stable rejection that never contains a received file name or content. ## FileImportResult type ```ts type FileImportResult = { ok: true; drafts: readonly FileImportDraft[] } | { ok: false; issue: FileImportIssue; index?: number }; ``` Accepted import drafts or a value-free rejection. ## InstalledAppFileHandle type ```ts type InstalledAppFileHandle = { readonly kind: "file"; readonly name: string; getFile(): Promise; }; ``` A minimal read-only file handle delivered by an installed-app launch. ## InstallFileLaunchConsumerOptions type ```ts type InstallFileLaunchConsumerOptions = PrepareFileImportOptions & { queue?: InstalledAppLaunchQueue; onDrafts(drafts: readonly FileImportDraft[]): void; onRejected?(issue: FileImportIssue, index?: number): void }; ``` Options for consuming installed file launches into preview-only drafts. ## PrepareFileImportOptions type ```ts type PrepareFileImportOptions = { accept: FileImportAccept; maxFiles: number; maxFileBytes: number; maxTotalBytes?: number; parse(file: File): Parsed | Promise; }; ``` Limits and product-owned content parser for preparing import drafts. --- # Launch handler ```ts import * as mod from "jsr:@nzip/lofi/recipes/launch-handler"; ``` Opt-in helpers for installed-app launch handling and safe client reuse. ## installInstalledAppLaunchConsumer function ```ts function installInstalledAppLaunchConsumer(options: InstallLaunchConsumerOptions): InstalledLaunchConsumer ``` Feature-detect and install one early launch-queue consumer. The most recently installed consumer owns the queue. Disposing an older development/HMR generation cannot replace the newer consumer; disposing the current owner installs a no-op consumer so stale callbacks stop handling launches. ## installInstalledAppLaunchQueueConsumer function ```ts function installInstalledAppLaunchQueueConsumer(options: InstallLaunchQueueConsumerOptions): InstalledLaunchConsumer ``` Feature-detect and install one raw launch-queue consumer. This is the composition seam used by recipes such as file handling. The most recently installed consumer owns the queue, including across different lofi launch recipes. Treat every delivered parameter as untrusted input. ## parseInstalledAppLaunchTarget function ```ts function parseInstalledAppLaunchTarget(targetURL: string | undefined, scopeValue: string | URL): InstalledAppLaunchResult ``` Parse one browser-provided launch URL against an exact origin and path scope. ## InstalledAppLaunchIssue type ```ts type InstalledAppLaunchIssue = | "missing-target" | "invalid-target" | "credentialed-target" | "outside-origin" | "outside-scope"; ``` Stable rejection that never contains the received launch URL. ## InstalledAppLaunchParameters type ```ts type InstalledAppLaunchParameters = { targetURL?: string; files?: readonly FileSystemHandle[]; }; ``` Launch data delivered by a browser `LaunchQueue`. ## InstalledAppLaunchQueue type ```ts type InstalledAppLaunchQueue = { setConsumer(consumer: (parameters: InstalledAppLaunchParameters) => void): void; }; ``` Minimal browser launch-queue seam used by the recipe. ## InstalledAppLaunchResult type ```ts type InstalledAppLaunchResult = { ok: true; target: InstalledAppLaunchTarget } | { ok: false; issue: InstalledAppLaunchIssue }; ``` Accepted in-scope launch target or value-free rejection. ## InstalledAppLaunchTarget type ```ts type InstalledAppLaunchTarget = { url: string; }; ``` A parsed launch URL proven to stay inside the configured application scope. ## InstalledLaunchConsumer type ```ts type InstalledLaunchConsumer = { supported: boolean; dispose(): void; }; ``` Result of feature-detecting and registering the launch consumer. ## InstallLaunchConsumerOptions type ```ts type InstallLaunchConsumerOptions = { scope: string | URL; queue?: InstalledAppLaunchQueue; onLaunch(target: InstalledAppLaunchTarget): void; onRejected?(issue: InstalledAppLaunchIssue): void; }; ``` Options for registering one launch-queue consumer. ## InstallLaunchQueueConsumerOptions type ```ts type InstallLaunchQueueConsumerOptions = { queue?: InstalledAppLaunchQueue; onLaunch(parameters: InstalledAppLaunchParameters): void; }; ``` Options for registering one raw browser launch-queue consumer. --- # Protocol handler ```ts import * as mod from "jsr:@nzip/lofi/recipes/protocol-handler"; ``` Opt-in parser for a narrow custom-protocol collaborative-list deep link. ## parseCollaborativeListProtocolTarget function ```ts function parseCollaborativeListProtocolTarget(search: string | URLSearchParams, options: ParseCollaborativeListProtocolOptions): CollaborativeListProtocolResult ``` Decode the handler query once and allow-list one collaborative-list item. Expected payload: `web+scheme:collaborative-list/LIST_ID/item/ITEM_ID`. IDs are restricted to portable unreserved characters, and the result contains identifiers only. The received URL is never returned or used as a redirect. ## CollaborativeListProtocolIssue type ```ts type CollaborativeListProtocolIssue = | "missing-target" | "duplicate-target" | "unexpected-parameter" | "target-too-long" | "invalid-target" | "wrong-protocol" | "unsupported-shape" | "invalid-list-id" | "invalid-item-id"; ``` Stable rejection that never contains the received protocol URL. ## CollaborativeListProtocolResult type ```ts type CollaborativeListProtocolResult = { ok: true; target: CollaborativeListProtocolTarget } | { ok: false; issue: CollaborativeListProtocolIssue }; ``` Accepted item reference or value-free rejection. ## CollaborativeListProtocolTarget type ```ts type CollaborativeListProtocolTarget = { readonly listId: string; readonly itemId: string; }; ``` A validated collaborative-list item reference, never an arbitrary redirect. ## ParseCollaborativeListProtocolOptions type ```ts type ParseCollaborativeListProtocolOptions = { protocol: string; parameter?: string; maxLength?: number; }; ``` Options matching one `protocol_handlers` manifest entry. --- # Related-app discovery ```ts import * as mod from "jsr:@nzip/lofi/recipes/related-app-discovery"; ``` Opt-in presentation-only discovery for verified related applications. ## discoverRelatedApplications function ```ts async function discoverRelatedApplications(options: DiscoverRelatedApplicationsOptions): Promise ``` Discover installed companions and return only exact allow-list matches. Use the result to adjust presentation such as redundant onboarding. It is intentionally unsuitable for authentication or authorization: unknown fields and versions are discarded, and errors degrade to the normal PWA experience. ## isBoundedManifestString function ```ts function isBoundedManifestString(value: unknown): value is string ``` Bounded, trimmed, control-character-free manifest string field. ## relatedApplicationPlatforms const ```ts const relatedApplicationPlatforms: ReadonlySet; ``` The one shared `related_applications` grammar: manifest validation imports these so the validator can never accept a declaration this recipe rejects. ## DiscoverRelatedApplicationsOptions type ```ts type DiscoverRelatedApplicationsOptions = { allow: readonly VerifiedRelatedApplication[]; client?: RelatedApplicationClient; }; ``` Options for matching browser results against product-owned declarations. ## RelatedApplicationClient type ```ts type RelatedApplicationClient = { getInstalledRelatedApps(): Promise; }; ``` Minimal browser discovery client used by the recipe. ## RelatedApplicationDiscovery type ```ts type RelatedApplicationDiscovery = { status: "unsupported" | "none" | "failed"; installed: readonly [] } | { status: "installed"; installed: readonly VerifiedRelatedApplication[] }; ``` Presentation-only discovery result. ## VerifiedRelatedApplication type ```ts type VerifiedRelatedApplication = { readonly platform: string; readonly id?: string; readonly url?: string; }; ``` A product-owned related application declaration. --- # Scope extension ```ts import * as mod from "jsr:@nzip/lofi/recipes/scope-extension"; ``` Opt-in deployment helpers for reciprocal PWA scope extensions. ## createScopeExtension function ```ts function createScopeExtension(origin: string): ScopeExtensionDeclaration ``` Create one validated manifest declaration without adding it to the starter. ## createWebAppOriginAssociation function ```ts function createWebAppOriginAssociation(options: WebAppOriginAssociationOptions): Readonly> ``` Create the JSON value for `/.well-known/web-app-origin-association`. Deploy this file on the extending origin before adding its declaration to the primary app manifest. ## verifyWebAppOriginAssociation function ```ts function verifyWebAppOriginAssociation(value: unknown, options: WebAppOriginAssociationOptions): boolean ``` Verify that unknown association JSON contains the exact manifest ID and scope. Extra manifest entries are allowed, but the matched entry may contain only `scope`. ## ScopeExtensionDeclaration type ```ts type ScopeExtensionDeclaration = { readonly type: "origin"; readonly origin: string; }; ``` A manifest `scope_extensions` entry owned by this product. ## WebAppOriginAssociationEntry type ```ts type WebAppOriginAssociationEntry = { readonly scope: string; }; ``` The value published under an exact manifest ID in an origin association file. ## WebAppOriginAssociationOptions type ```ts type WebAppOriginAssociationOptions = { manifestId: string; scope: string; }; ``` Options shared by association generation and verification. --- # Web Share ```ts import * as mod from "jsr:@nzip/lofi/recipes/web-share"; ``` Opt-in helpers for outbound Web Share and inbound text/link share targets. The helpers feature-detect browser support and parse received values without mutating application data. Add the matching manifest member and receiver UI only when the product has a concrete sharing workflow. ## parseTextShareTarget function ```ts function parseTextShareTarget(input: URL | URLSearchParams | string, options: ParseTextShareTargetOptions): TextShareTargetResult ``` Parse an inbound GET share target as an untrusted draft. Unknown parameters are ignored. Duplicate, oversized, malformed, and non-HTTP(S) values reject the whole draft. The caller must still present the returned draft for explicit user confirmation before persisting anything. ## shareOrFallback function ```ts async function shareOrFallback(data: WebShareData, options: ShareOrFallbackOptions): Promise ``` Attempt a user-triggered native share and use a normal web fallback only when the capability is unavailable or rejects the payload up front. Call this function directly from a click/submit handler: browsers require transient user activation for `navigator.share()`. A runtime share failure is reported as `failed` so callers can offer a retry or explicit copy action without unexpectedly copying data after the native sheet opened. ## TEXT_SHARE_LIMITS const ```ts const TEXT_SHARE_LIMITS: Readonly; ``` Default limits for the text/link share-target recipe. ## ParseTextShareTargetOptions type ```ts type ParseTextShareTargetOptions = { names?: Partial; limits?: Partial; }; ``` Optional manifest-name and input-limit overrides for the inbound parser. ## ShareOrFallbackOptions type ```ts type ShareOrFallbackOptions = { client?: WebShareClient; fallback: (data: WebShareData) => void | Promise; }; ``` Browser seam and fallback used for one outbound share attempt. ## TextShareTargetDraft type ```ts type TextShareTargetDraft = { title?: string; text?: string; url?: string; }; ``` Parsed text/link values awaiting explicit user confirmation. ## TextShareTargetIssue type ```ts type TextShareTargetIssue = | "duplicate-title" | "duplicate-text" | "duplicate-url" | "title-too-long" | "text-too-long" | "url-too-long" | "invalid-url" | "unsupported-url-protocol" | "empty-share"; ``` Stable validation issue that never contains received share values. ## TextShareTargetLimits type ```ts type TextShareTargetLimits = { title: number; text: number; url: number; }; ``` Length limits applied to received title, text, and URL values. ## TextShareTargetNames type ```ts type TextShareTargetNames = { title: string; text: string; url: string; }; ``` Query-parameter names declared by the matching manifest share target. ## TextShareTargetResult type ```ts type TextShareTargetResult = { ok: true; draft: TextShareTargetDraft; issues: readonly [] } | { ok: false; issues: readonly TextShareTargetIssue[] }; ``` Accepted draft or value-free rejection from an inbound share target. ## WebShareClient type ```ts type WebShareClient = { canShare?: (data: WebShareData) => boolean; share?: (data: WebShareData) => Promise; }; ``` Minimal browser seam used by `shareOrFallback`. ## WebShareData type ```ts type WebShareData = { title?: string; text?: string; url?: string; files?: readonly File[]; }; ``` Data accepted by the browser Web Share API. ## WebShareOutcome type ```ts type WebShareOutcome = | "shared" | "cancelled" | "fallback" | "failed"; ``` Stable, value-free outcome from one outbound share attempt. --- # Window controls overlay ```ts import * as mod from "jsr:@nzip/lofi/recipes/window-controls-overlay"; ``` Opt-in geometry observation for desktop Window Controls Overlay layouts. ## observeWindowControlsOverlay function ```ts function observeWindowControlsOverlay(options: ObserveWindowControlsOverlayOptions): WindowControlsOverlayObserver ``` Observe the experimental Window Controls Overlay geometry surface. CSS `titlebar-area-*` environment variables should own collision-free layout. This observer is for state that genuinely needs geometry changes. Unsupported browsers retain the ordinary standalone header and receive no callback. ## ObserveWindowControlsOverlayOptions type ```ts type ObserveWindowControlsOverlayOptions = { client?: WindowControlsOverlayClient; onGeometry(geometry: WindowControlsOverlayGeometry): void; }; ``` Options for observing overlay visibility and geometry changes. ## WindowControlsOverlayClient type ```ts type WindowControlsOverlayClient = { readonly visible: boolean; getTitlebarAreaRect(): Pick; addEventListener(type: "geometrychange", listener: EventListener): void; removeEventListener(type: "geometrychange", listener: EventListener): void; }; ``` Minimal browser surface consumed by this recipe. ## WindowControlsOverlayGeometry type ```ts type WindowControlsOverlayGeometry = { readonly visible: boolean; readonly titlebarArea: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }; }; ``` A value-only copy of the titlebar area available to application content. ## WindowControlsOverlayObserver type ```ts type WindowControlsOverlayObserver = { readonly supported: boolean; dispose(): void; }; ``` Disposable result from installing the geometry observer.