* docs: mark M1 device-matrix deferrals as superseded in spike records The frozen M1 spike evidence still read as current status: the physical iPhone/Android install matrix "deferred to M3" and the passkey device matrix deferred with the rejected alpha mechanism. Both graduated since (M3 mobile PWA validation; DX-DEVICE-01/DX-DEVICE-UX-01/DX-OFFLINE-01 validated), so each record now carries a supersession note pointing at the contract instead of inviting re-citation as a live gap. * fix(create): publish-safe starter sources and project-name personalization Publishing rewrites the specifiers inside every module in the package, so the generated starter shipped with direct jsr:/npm: imports that bypassed the project's single exact pin and with JSX pragmas collapsed into the first import line, failing deno fmt --check. The .ts/.tsx starter files are now also published as verbatim .txt mirrors under package/starter/, which publishing leaves alone; a test keeps the mirror byte-identical to the reference app. createProject now personalizes the copy with the requested project name: app.ts name and databaseName, the install manifest name/short_name/id, and the index page title. The reference app's repository link stays out of generated projects — the footer renders only when repositoryUrl is set. The generated README is reflowed to the starter's fmt style, and the snapshot test now asserts personalization, rejects direct jsr:/npm: specifiers in generated sources, and runs deno fmt --check over the generated tree. * fix(runtime): serialize account-secret store access behind a Web Lock The vendor secret store persists the account secret with a localStorage check-then-write that is not atomic across tabs: two first tabs could each mint a secret, the last write would win, and the losing tab's data would strand under an identity that disappears on reload. First-boot creation, the session read-through, and recovery's secret replacement now run under one exclusive app-scoped Web Lock. The durable capability gate already requires Web Locks before boot; without a lock manager (single-process tests) the operation runs directly. The module is vendored into .lofi/ with the rest of the runtime. * docs(create): mark the starter mirror as generated A README in package/starter/ and linguist-generated attributes make the mirror's generated status visible to readers and tooling; the byte- identity test already reverts direct edits. Edits belong in apps/reference, refreshed via deno task test:update:create.
- commit
- 8c5b010b25a0c5c7fdaa5f5465f45690b585720d
- parent
- 879fb711851577b692b233b6de0f1afe8d4ed380
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -20,7 +20,11 @@ import "../styles/global.css";
2020 <AccountGate client:load />2121 <TaskList client:load />2222 <DeviceStatus client:load />23- <footer>24- <a href={app.repositoryUrl} rel="noreferrer">lofi on GitHub →</a>25- </footer>23+ {24+ app.repositoryUrl && (25+ <footer>26+ <a href={app.repositoryUrl} rel="noreferrer">Source repository →</a>27+ </footer>28+ )29+ }2630 </Shell>@@ -79,6 +79,10 @@ site-data loss.
7979 8080 ## Deferred device matrix8181 82+> Status as recorded at M1, scoped to the rejected alpha mechanism. The replacement recoverable83+> passkey mechanism has since shipped and validated DX-AUTH-01/DX-DEVICE-01; do not cite this84+> deferral as current status.85+8286 Fresh profile, deleted passkey, same-ecosystem restore, cross-platform restore, iOS tab/Home Screen,8387 and Android browser/installed-app passkey cases are **not run** because the candidate mechanism8488 failed the pre-device security gate. Reopen the matrix only for a replacement mechanism with a@@ -34,6 +34,10 @@ This is preflight only. It does not replace the required physical iPhone and And
3434 3535 ## Physical matrix status3636 37+> Status as recorded at M1. The physical matrix has since run and graduated at M3; see DX-DEVICE-01,38+> DX-DEVICE-UX-01, and DX-OFFLINE-01 in the developer-experience contract. Do not cite this table as39+> current status.40+3741 | Surface | Status |3842 | ----------------------- | -------------- |3943 | iPhone Safari tab | deferred to M3 |@@ -64,18 +64,19 @@ on-device account with no sign-in. To enable managed sync and account backup, ru
6464 6565 ## Accounts: back up & recover6666 67-With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local account,68-restore it with a recoverable passkey, or use the portable 24-word recovery phrase fallback. The69-account they already have carries over — electing to sync never changes its identity. Passkey restore70-is scoped to the app's stable relying-party ID; provider syncing is not universally portable. The71-framework implementation is provided by \`@nzip/lofi\`.67+With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local68+account, restore it with a recoverable passkey, or use the portable 24-word recovery phrase69+fallback. The account they already have carries over — electing to sync never changes its identity.70+Passkey restore is scoped to the app's stable relying-party ID; provider syncing is not universally71+portable. The framework implementation is provided by \`@nzip/lofi\`.7272 7373 ## Sharing and groups7474 7575 The optional \`@nzip/lofi/access\` entrypoint provides private, direct-share, and fixed-role group7676 templates over ordinary Jazz schemas. Collaboration requires configured sync and fails explicitly in7777 local-only mode. Start with the78-[permissions guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/permissions.md) and the78+[permissions guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/permissions.md) and79+the7980 [access API reference](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/access.md).8081 8182 ## Optional installed-app recipes@@ -84,17 +85,17 @@ OS-facing capabilities remain absent until the product opts in. Start with the
8485 [installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)8586 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web8687 fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated87-file-import previews, custom protocol links, and presentation-only companion discovery without adding88-any to the starter. Cross-origin app-window scope and desktop titlebar geometry remain explicit89-experimental opt-ins.88+file-import previews, custom protocol links, and presentation-only companion discovery without89+adding any to the starter. Cross-origin app-window scope and desktop titlebar geometry remain90+explicit experimental opt-ins.9091 9192 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9293 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.9394 9495 Start with the framework's9596 [generated-app guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/getting-started.md)96-when replacing the task example with your own schema, permissions, hook, and UI.97-The [exact generated-project map](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/project-layout.md)97+when replacing the task example with your own schema, permissions, hook, and UI. The98+[exact generated-project map](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/project-layout.md)9899 lists every source-controlled path and its ownership.99100 100101 Runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration come from the one pinned@@ -102,8 +103,8 @@ Runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration come from
102103 103104 ## Hosting104105 105-\`deno task build\` emits a static PWA in \`dist/\`. The deploy tasks host it on Deno Deploy as a106-static site — they push the built \`dist/\` as the deploy root, which serves it as plain assets:106+\`deno task build\` emits a static PWA in \`dist/\`. The deploy tasks host it on Deno Deploy as a static107+site — they push the built \`dist/\` as the deploy root, which serves it as plain assets:107108 108109 - \`deno task deploy:create --org <org> --app <app>\` — one-time: create the app from \`dist/\`.109110 - \`deno task deploy\` — thereafter: build and push \`dist/\`.@@ -111,8 +112,8 @@ static site — they push the built \`dist/\` as the deploy root, which serves i
111112 Point them at any other static host by editing those two tasks.112113 113114 Deployments use the origin root by default. If the host mounts the app below it, copy114-\`.env.example\`, set \`LOFI_BASE_PATH=/my-app/\`, and rebuild. Upload \`dist/\` at that same path;115-the manifest, public assets, service worker, worker scope, and preview URL all follow this value.115+\`.env.example\`, set \`LOFI_BASE_PATH=/my-app/\`, and rebuild. Upload \`dist/\` at that same path; the116+manifest, public assets, service worker, worker scope, and preview URL all follow this value.116117 `;117118 }118119 @@ -241,6 +242,48 @@ async function assertNoSymlinkPath(cwd: string, segments: readonly string[]): Pr
241242 }242243 }243244 245+async function rewriteStarterFile(246+ root: string,247+ relativePath: string,248+ edits: ReadonlyArray<readonly [marker: string, replacement: string]>,249+): Promise<void> {250+ const path = join(root, relativePath);251+ let content = await Deno.readTextFile(path);252+ for (const [marker, replacement] of edits) {253+ if (!content.includes(marker)) {254+ throw new Error(255+ `starter ${relativePath} is missing the ${JSON.stringify(marker)} marker`,256+ );257+ }258+ content = content.replaceAll(marker, replacement);259+ }260+ await Deno.writeTextFile(path, content);261+}262+263+// The starter is a copy of the reference app, so the identity it carries — app264+// and database names, install manifest, repository link — belongs to lofi, not265+// to the new project. Rewrite that identity to the requested name and drop the266+// repository link; the index page only renders its footer link when267+// `repositoryUrl` is present.268+async function personalizeStarter(root: string, name: string): Promise<void> {269+ const quoted = JSON.stringify(name);270+ await rewriteStarterFile(root, join("src", "app.ts"), [271+ ['name: "lofi-prototype"', `name: ${quoted}`],272+ ['databaseName: "lofi-prototype"', `databaseName: ${quoted}`],273+ ['\n repositoryUrl: "https://github.com/FelineStateMachine/lofi",', ""],274+ ]);275+ await rewriteStarterFile(root, join("src", "pages", "index.astro"), [276+ ['<Shell title="lofi starter">', `<Shell title=${quoted}>`],277+ ['<p class="eyebrow">lofi</p>', `<p class="eyebrow">${name}</p>`],278+ ]);279+ const manifestPath = join(root, "public", "manifest.webmanifest");280+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));281+ manifest.name = name;282+ manifest.short_name = name;283+ manifest.id = `./${name}`;284+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);285+}286+244287 async function writeTemplate(destination: string): Promise<void> {245288 // Fetch concurrently: remote creation (jsr:) otherwise pays one network246289 // round trip per starter file.@@ -282,6 +325,7 @@ export async function createProject(options: CreateProjectOptions): Promise<Crea
282325 });283326 try {284327 await writeTemplate(staging);328+ await personalizeStarter(staging, basename(destination));285329 await rewritePortableDenoConfig(staging, options.packagePrefix ?? LOFI_PACKAGE_PREFIX);286330 await Deno.writeTextFile(join(staging, ".gitignore"), generatedGitignore);287331 await Deno.writeTextFile(join(staging, ".env.example"), generatedEnvironment);@@ -1,6 +1,7 @@
11 import { join } from "node:path";22 import packageManifest from "../deno.json" with { type: "json" };33 import { createProject } from "./create_core.ts";4+import { STARTER_FILES } from "./starter_template.ts";45 import { LOFI_VERSION } from "./version.ts";56 67 function assert(condition: unknown, message: string): asserts condition {@@ -75,6 +76,53 @@ function makeTestRoot(): Promise<string> {
7576 return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });7677 }7778 79+// Declared before the snapshot test so that snapshot updates regenerate the80+// mirror first and the snapshot is taken from current sources.81+Deno.test("starter mirror stays byte-identical to the reference app sources", async () => {82+ const mirrored = STARTER_FILES.filter((path) => /\.tsx?$/.test(path));83+ const update = Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1";84+ const stale: string[] = [];85+ for (const relativePath of mirrored) {86+ const source = await Deno.readFile(87+ new URL(`../apps/reference/${relativePath}`, import.meta.url),88+ );89+ const mirrorUrl = new URL(`./starter/${relativePath}.txt`, import.meta.url);90+ if (update) {91+ await Deno.mkdir(new URL(".", mirrorUrl), { recursive: true });92+ await Deno.writeFile(mirrorUrl, source);93+ continue;94+ }95+ const mirror = await Deno.readFile(mirrorUrl).catch(() => null);96+ if (97+ mirror === null || mirror.length !== source.length ||98+ mirror.some((byte, index) => byte !== source[index])99+ ) {100+ stale.push(relativePath);101+ }102+ }103+ assert(104+ stale.length === 0,105+ `starter mirror is stale for: ${106+ stale.join(", ")107+ }. Run \`deno task test:update:create\` to refresh package/starter.`,108+ );109+ const expectedMirrorFiles = new Set([...mirrored.map((path) => `${path}.txt`), "README.md"]);110+ const mirrorRoot = new URL("./starter/", import.meta.url);111+ const strays: string[] = [];112+ async function visit(url: URL, prefix: string): Promise<void> {113+ for await (const entry of Deno.readDir(url)) {114+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;115+ if (entry.isDirectory) await visit(new URL(`${entry.name}/`, url), relative);116+ else if (!expectedMirrorFiles.has(relative)) strays.push(relative);117+ }118+ }119+ await visit(mirrorRoot, "");120+ assert(121+ strays.length === 0,122+ `starter mirror contains files with no reference-app source: ${strays.join(", ")}`,123+ );124+});125+78126 Deno.test("createProject materializes the complete starter snapshot", async () => {79127 const cwd = await makeTestRoot();80128 try {@@ -113,6 +161,44 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
113161 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.8.1/")),114162 "generated lofi commands do not resolve through one exact package version",115163 );164+ const appSource = await Deno.readTextFile(join(result.destination, "src", "app.ts"));165+ assert(appSource.includes('name: "starter"'), "generated app.ts does not carry the app name");166+ assert(167+ appSource.includes('databaseName: "starter"'),168+ "generated app.ts does not carry the database namespace",169+ );170+ assert(171+ !appSource.includes("lofi-prototype") && !appSource.includes("repositoryUrl"),172+ "generated app.ts still carries the reference app's identity",173+ );174+ const manifest = JSON.parse(175+ await Deno.readTextFile(join(result.destination, "public", "manifest.webmanifest")),176+ );177+ assertEquals(178+ [manifest.name, manifest.short_name, manifest.id],179+ ["starter", "starter", "./starter"],180+ );181+ const index = await Deno.readTextFile(182+ join(result.destination, "src", "pages", "index.astro"),183+ );184+ assert(index.includes('<Shell title="starter">'), "generated index.astro keeps the lofi title");185+ for (const relativePath of STARTER_FILES.filter((path) => /\.(?:tsx?|astro)$/.test(path))) {186+ const source = await Deno.readTextFile(join(result.destination, relativePath));187+ assert(188+ !source.includes("jsr:@nzip/lofi") && !source.includes("npm:preact"),189+ `generated ${relativePath} bypasses the pinned import map with a direct specifier`,190+ );191+ }192+ const fmt = await new Deno.Command(Deno.execPath(), {193+ args: ["fmt", "--check"],194+ cwd: result.destination,195+ stdout: "piped",196+ stderr: "piped",197+ }).output();198+ assert(199+ fmt.success,200+ `generated project fails deno fmt --check:\n${new TextDecoder().decode(fmt.stderr)}`,201+ );116202 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];117203 for (118204 const icon of [@@ -0,0 +1,53 @@
1+/**2+ * Cross-tab coordination for account-secret store access.3+ *4+ * The vendor secret store persists the account secret with a localStorage5+ * check-then-write, which is not atomic across tabs: two first tabs could6+ * each mint a secret, the last write would win, and the losing tab would7+ * spend its session writing under an identity that disappears on reload.8+ * Serializing every read-or-create and replacement behind one exclusive,9+ * app-scoped Web Lock closes that window — the second tab enters the store10+ * only after the first tab's write is visible.11+ *12+ * The durable capability gate requires Web Locks before the runtime opens, so13+ * a browser boot always holds the lock. Without a lock manager (single-process14+ * tests) the operation runs directly; there is no concurrent tab to exclude.15+ *16+ * @module17+ */18+19+/** Minimal Web Locks surface, injectable for tests. */20+export type AuthSecretLockManager = {21+ request<T>(22+ name: string,23+ options: { mode: "exclusive" },24+ callback: (lock: unknown) => Promise<T> | T,25+ ): Promise<T>;26+};27+28+/** The exclusive Web Lock name serializing one app's secret-store access. */29+export function authSecretLockName(appId: string): string {30+ const key = appId.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";31+ return `lofi-auth-secret-${key}`;32+}33+34+function defaultLocks(): AuthSecretLockManager | undefined {35+ const locks = typeof navigator === "undefined" ? undefined : navigator.locks;36+ return typeof locks?.request === "function"37+ ? locks as unknown as AuthSecretLockManager38+ : undefined;39+}40+41+/** Runs one secret-store operation under the app's exclusive secret lock. */42+export async function withAuthSecretLock<T>(43+ appId: string,44+ operation: () => Promise<T>,45+ locks: AuthSecretLockManager | undefined = defaultLocks(),46+): Promise<T> {47+ if (!locks) return await operation();48+ return await locks.request(49+ authSecretLockName(appId),50+ { mode: "exclusive" },51+ () => operation(),52+ );53+}@@ -0,0 +1,78 @@
1+import {2+ type AuthSecretLockManager,3+ authSecretLockName,4+ withAuthSecretLock,5+} from "./auth-secret-lock.ts";6+import { assert } from "./test-assert.ts";7+8+// An in-memory exclusive lock manager: requests on one name run one at a9+// time, in arrival order, like tabs on one origin.10+function memoryLocks(): AuthSecretLockManager {11+ const queues = new Map<string, Promise<unknown>>();12+ return {13+ request(name, _options, callback) {14+ const previous = queues.get(name) ?? Promise.resolve();15+ const run = previous.then(() => callback(undefined));16+ queues.set(name, run.catch(() => undefined));17+ return run;18+ },19+ };20+}21+22+// A store with the vendor's first-visit shape: a non-atomic check-then-write23+// that yields between the check and the write, so unserialized concurrent24+// callers mint rival secrets.25+function checkThenWriteStore() {26+ const state = { stored: null as string | null, minted: 0, active: 0, overlapped: false };27+ return {28+ state,29+ async getOrCreate(): Promise<string> {30+ state.active += 1;31+ if (state.active > 1) state.overlapped = true;32+ const checked = state.stored;33+ await new Promise((resolve) => setTimeout(resolve, 1));34+ let value = checked;35+ if (value === null) {36+ state.minted += 1;37+ value = `secret-${state.minted}`;38+ state.stored = value;39+ }40+ state.active -= 1;41+ return value;42+ },43+ };44+}45+46+Deno.test("concurrent first-boot creations serialize to one secret", async () => {47+ const locks = memoryLocks();48+ const store = checkThenWriteStore();49+ const [first, second] = await Promise.all([50+ withAuthSecretLock("app-1", store.getOrCreate, locks),51+ withAuthSecretLock("app-1", store.getOrCreate, locks),52+ ]);53+ assert(!store.state.overlapped, "secret-store operations overlapped under the lock");54+ assert(store.state.minted === 1, `expected one minted secret, received ${store.state.minted}`);55+ assert(first === "secret-1" && second === "secret-1", "tabs resolved different secrets");56+});57+58+Deno.test("unserialized check-then-write mints rival secrets (control)", async () => {59+ const store = checkThenWriteStore();60+ const [first, second] = await Promise.all([store.getOrCreate(), store.getOrCreate()]);61+ assert(store.state.minted === 2, "control run was expected to race");62+ assert(first !== second, "control run was expected to mint rival secrets");63+});64+65+Deno.test("without a lock manager the operation runs directly", async () => {66+ const store = checkThenWriteStore();67+ const secret = await withAuthSecretLock("app-1", store.getOrCreate, undefined);68+ assert(secret === "secret-1", "fallback path did not run the operation");69+});70+71+Deno.test("lock names are app-scoped and normalized", () => {72+ assert(authSecretLockName("demo.app/1") === "lofi-auth-secret-demo-app-1", "unexpected name");73+ assert(74+ authSecretLockName("a") !== authSecretLockName("b"),75+ "different apps must not share a lock",76+ );77+ assert(authSecretLockName("") === "lofi-auth-secret-app", "empty app id needs a stable name");78+});@@ -20,6 +20,7 @@ import { sharedColumnConfigs } from "../schema/shared-registry.ts";
2020 import { recordSharedFieldAlert } from "./diagnostics.ts";2121 import { activeAppId } from "./config.ts";2222 import { completePopExchange, getOrCreatePopKeyPair } from "./pop.ts";23+import { withAuthSecretLock } from "./auth-secret-lock.ts";2324 import { assertDurableBrowser } from "./device-capabilities.ts";2425 import {2526 createTableStore,@@ -105,8 +106,10 @@ function recordStartupFailure(state: RuntimeSlot, failure: RuntimeStartupFailure
105106 // later election to back up and sync (see `session.ts`) keeps all existing106107 // data. Recovering from a phrase replaces the cached secret and recreates the107108 // runtime, so the restored account's synced data opens under the same identity.109+// Creation runs under the app's exclusive secret lock: two first tabs must not110+// each mint a secret (see `auth-secret-lock.ts`).108111 async function resolveAccountSecret(): Promise<string> {109- return await BrowserAuthSecretStore.getOrCreateSecret({ appId });112+ return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));110113 }111114 112115 async function accountNamespace(secret: string): Promise<string> {@@ -540,7 +543,7 @@ export function replaceRuntimePrincipal(
540543 // a restore report success without replacing the principal.541544 await previous?.catch(() => undefined);542545 const saveSecret = async () => {543- await BrowserAuthSecretStore.saveSecret(secret, { appId });546+ await withAuthSecretLock(appId, () => BrowserAuthSecretStore.saveSecret(secret, { appId }));544547 options.onSecretSaved?.();545548 };546549 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {@@ -19,6 +19,7 @@
1919 2020 import { BrowserAuthSecretStore } from "jazz-tools";2121 import { getLofiApp } from "./app.ts";22+import { withAuthSecretLock } from "./auth-secret-lock.ts";2223 import {2324 activeSink,2425 appId,@@ -190,9 +191,10 @@ export async function readAccountSession(): Promise<Session> {
190191 191192 // The account secret the runtime is already using. `getOrCreateSecret` is a192193 // read-through: the runtime created it on first boot, so this returns that same193-// secret rather than minting a new one.194+// secret rather than minting a new one. It still takes the app's secret lock so195+// a call that races a first boot in another tab observes that boot's secret.194196 async function currentSecret(): Promise<string> {195- return await BrowserAuthSecretStore.getOrCreateSecret({ appId });197+ return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));196198 }197199 198200 /**@@ -0,0 +1,10 @@
1+# Generated mirror — do not edit2+3+Every `.txt` file in this directory is a byte-identical, generated copy of a `.ts`/`.tsx` starter4+file in `apps/reference/`. The mirror exists only for publishing: the registry rewrites the5+specifiers inside every published module, so the generator reads the starter's TypeScript from these6+non-module copies instead.7+8+Edit the source under `apps/reference/`, then refresh the mirror with the9+`deno task test:update:create` task. A direct edit here cannot survive `deno task check` — the10+mirror test fails any file that drifts from its source.@@ -0,0 +1,35 @@
1+import { defineLofiApp } from "@nzip/lofi";2+import { app as schema } from "./schema.ts";3+4+/** Author-owned composition. Vendor URLs and browser drivers remain in package runtime code. */5+export const app = defineLofiApp({6+ name: "lofi-prototype",7+ databaseName: "lofi-prototype",8+ schema,9+ storage: "durable" as const,10+ // Identity is local-first: first boot opens a private, on-device account with11+ // no sign-in. When a managed Jazz app is configured (JAZZ_APP_ID / JAZZ_SERVER_URL —12+ // see `deno task jazz:provision`), the AccountGate island lets the user elect to13+ // back up and sync it, and recover it from a passkey or phrase. See the framework's14+ // `docs/sync-and-recovery.md` guide.15+ //16+ // Hostnames you have committed to keeping stable, for the optional device-credential17+ // primitive in `@nzip/lofi` (WebAuthn/PRF at-rest encryption). A credential binds18+ // to its origin: localhost works for development without configuration, but a19+ // deployed origin must be listed here before credentials can enroll on it. An20+ // unlisted host is refused so a preview hostname cannot mint passkeys and21+ // PRF-protected data that strand when the app moves to its production origin.22+ credentialOrigins: [] as readonly string[],23+ // Pin this to the canonical production hostname before users create recoverable24+ // passkeys. Omit it in local development to use the current hostname.25+ // passkey: { rpId: "app.example.com" },26+ //27+ // Update-lifecycle preferences are optional; this app runs the defaults on28+ // purpose: when local data is newer than the cached shell, the framework's29+ // minimal read-only banner renders and stale tabs reload after an update.30+ // To render your own banner from useSchemaCompat and keep stale tabs open:31+ // pwa: { updateBanner: "none", staleTabs: "prompt" },32+ sync: { adapter: "jazz" as const },33+ // Source/home link shown in the starter footer. Point it at your own repo.34+ repositoryUrl: "https://github.com/FelineStateMachine/lofi",35+});@@ -0,0 +1,13 @@
1+/// <reference types="astro/client" />2+3+interface ImportMetaEnv {4+ readonly VITE_JAZZ_APP_ID?: string;5+ readonly VITE_JAZZ_SERVER_URL?: string;6+}7+8+interface ImportMeta {9+ readonly env: ImportMetaEnv;10+}11+12+declare const __LOFI_JAZZ_APP_ID__: string;13+declare const __LOFI_JAZZ_SERVER_URL__: string;@@ -0,0 +1,367 @@
1+import { useCallback, useEffect, useState } from "preact/hooks";2+import {3+ confirmPhraseAccess,4+ createBackupPasskey,5+ createRecoverablePasskeyBackup,6+ enableSyncBackup,7+ isAccountReplacementError,8+ isAuthError,9+ isRecoverablePasskeyError,10+ isRecoveryError,11+ readAccountSession,12+ restoreFromPasskey,13+ restoreFromRecoveryPhrase,14+ revealRecoveryPhrase,15+ type Session,16+ stopSyncBackup,17+} from "@nzip/lofi";18+import { encodeSharingIdentity } from "@nzip/lofi/access";19+import { TicketEnrollForm } from "@nzip/lofi/preact";20+21+/**22+ * Author-owned account example. lofi is local-first: the app already opened on a23+ * device-local account with no sign-in. This island offers the *opt-in* upgrade —24+ * back up and sync the account, and recover it elsewhere — using only the25+ * public `@nzip/lofi` session primitives. Delete it if your app is local-only,26+ * or restyle it freely; framework implementation remains package-owned.27+ *28+ * With no sync location — no compiled managed app and no enrolled ticket — it29+ * offers the connect step instead: enroll an app-connect ticket (a password30+ * manager autofills a saved one), and restore is available first, so a fresh31+ * device can recover its identity before or after choosing where to sync.32+ */33+34+type Busy =35+ | "enable"36+ | "stop"37+ | "reveal"38+ | "restore"39+ | "passkey-restore"40+ | "recoverable"41+ | "protect";42+43+// Turns any thrown value into a sentence a person can act on.44+function describe(error: unknown): string {45+ if (isAuthError(error)) {46+ switch (error.code) {47+ case "cancelled":48+ return "Passkey prompt dismissed — your recovery phrase was not shown.";49+ case "unsupported":50+ return "This browser does not support passkeys.";51+ default:52+ return error.message;53+ }54+ }55+ if (isRecoveryError(error)) return error.message;56+ if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;57+ return error instanceof Error ? error.message : String(error);58+}59+60+export default function AccountGate() {61+ const [session, setSession] = useState<Session | null>(null);62+ const [busy, setBusy] = useState<Busy | null>(null);63+ const [error, setError] = useState<string | null>(null);64+ const [phrase, setPhrase] = useState<string | null>(null);65+ const [phraseInput, setPhraseInput] = useState("");66+ // Backup happens in two steps: reveal the phrase first, then enable sync67+ // only after the user confirms it is saved — enabling sync reloads the68+ // document, which would wipe an unread phrase from the screen.69+ const [pendingEnable, setPendingEnable] = useState(false);70+ const [restoring, setRestoring] = useState(false);71+ const [confirmReplacement, setConfirmReplacement] = useState(false);72+ const sharingIdentity = session?.user_id ? encodeSharingIdentity(session.user_id) : null;73+ // Set when a backup proceeded without a passkey guard (WebAuthn unavailable),74+ // so the phrase block can say so honestly rather than imply a confirmation.75+ const [unguarded, setUnguarded] = useState(false);76+77+ useEffect(() => {78+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));79+ }, []);80+81+ // Every account action funnels through here: one busy flag, one error line,82+ // and any returned Session becomes the new snapshot.83+ const run = useCallback(84+ (kind: Busy, action: () => Promise<Session | void>) => {85+ setBusy(kind);86+ setError(null);87+ void action().then(88+ (result) => {89+ if (result) setSession(result);90+ },91+ (cause) => setError(describe(cause)),92+ ).finally(() => setBusy(null));93+ },94+ [],95+ );96+97+ if (!session) return null;98+99+ const disabled = busy !== null;100+101+ // Restore works without a sync location: it replaces the local identity, and102+ // synced data arrives once a location exists. Rendered in both the connect103+ // and the back-up states.104+ const restoreBlock = (105+ <>106+ <div class="account-actions">107+ <button108+ type="button"109+ class="account-secondary"110+ disabled={disabled || !confirmReplacement}111+ onClick={() => {112+ setRestoring(true);113+ run("passkey-restore", () =>114+ restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {115+ setConfirmReplacement(false);116+ return next;117+ }));118+ }}119+ >120+ {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}121+ </button>122+ <button123+ type="button"124+ class="account-secondary"125+ disabled={disabled || !confirmReplacement}126+ onClick={() => {127+ setError(null);128+ setRestoring((value) =>129+ !value130+ );131+ }}132+ >133+ {restoring ? "Cancel" : "Restore from recovery phrase"}134+ </button>135+ </div>136+ <label class="account-note">137+ <input138+ type="checkbox"139+ checked={confirmReplacement}140+ disabled={disabled}141+ onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}142+ />{" "}143+ I understand restore replaces this device's current local account and unsynced data may be144+ lost.145+ </label>146+ {restoring && (147+ <div class="account-field">148+ <label for="recovery-input">Recovery phrase</label>149+ <textarea150+ id="recovery-input"151+ value={phraseInput}152+ onInput={(event) => setPhraseInput(event.currentTarget.value)}153+ placeholder="word one word two …"154+ rows={3}155+ disabled={disabled}156+ />157+ <p class="account-note">158+ Restoring replaces this device's local account with the recovered one — any data created159+ here that is not backed up will be discarded.160+ </p>161+ <button162+ type="button"163+ disabled={disabled || phraseInput.trim().length === 0}164+ onClick={() =>165+ run(166+ "restore",167+ () =>168+ restoreFromRecoveryPhrase(phraseInput, {169+ confirmLocalReplacement: confirmReplacement,170+ }).then((next) => {171+ setRestoring(false);172+ setPhraseInput("");173+ setConfirmReplacement(false);174+ return next;175+ }),176+ )}177+ >178+ {busy === "restore" ? "Restoring…" : "Restore account"}179+ </button>180+ </div>181+ )}182+ </>183+ );184+185+ // No sync location yet: offer the connect step, with restore available so a186+ // fresh device recovers its identity before or after choosing where to sync.187+ if (!session.syncAvailable) {188+ return (189+ <section class="account account-out" aria-labelledby="account-title">190+ <header>191+ <p class="eyebrow">Account</p>192+ <h2 id="account-title">Connect a sync location</h2>193+ </header>194+ <p>195+ You are working on a private, on-device account. To sync or recover it, connect a sync196+ location: paste the app-connect ticket from your node — a password manager fills a saved197+ one. Restoring an account works before or after connecting; synced data arrives once a198+ location exists.199+ </p>200+ <TicketEnrollForm201+ title="Enroll an app-connect ticket"202+ onEnrolled={() => {203+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));204+ }}205+ />206+ {restoreBlock}207+ {error && <p class="account-error" role="alert">{error}</p>}208+ </section>209+ );210+ }211+212+ const phraseBlock = phrase && (213+ <div class="account-phrase">214+ <p class="account-note">215+ Write these {phrase.split(" ").length}{" "}216+ words down and keep them somewhere safe. Anyone with them can open this account; lose every217+ copy and the account cannot be recovered.218+ </p>219+ {unguarded && (220+ <p class="account-note">221+ A passkey could not be created on this browser or origin, so this reveal was not confirmed222+ with one. Save the phrase carefully.223+ </p>224+ )}225+ <ol class="account-words" aria-label="Recovery phrase">226+ {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}227+ </ol>228+ </div>229+ );230+231+ if (session.backedUp) {232+ return (233+ <section class="account account-in" aria-labelledby="account-title">234+ <header>235+ <p class="eyebrow">Account</p>236+ <h2 id="account-title">Backed up & syncing</h2>237+ </header>238+ <p>239+ This account replicates to your managed Jazz app and can be recovered with its phrase.240+ {session.passkeyRecoverable241+ ? " Its account-recovery passkey can restore the same identity where your passkey provider makes it available."242+ : " Create an account-recovery passkey if this browser supports one."}243+ </p>244+ {sharingIdentity && (245+ <p class="account-note">246+ Share identity: <code data-sharing-identity>{sharingIdentity}</code>247+ </p>248+ )}249+ <div class="account-actions">250+ {!session.passkeyRecoverable && (251+ <button252+ type="button"253+ disabled={disabled}254+ onClick={() =>255+ run("recoverable", async () => {256+ await createRecoverablePasskeyBackup();257+ return readAccountSession();258+ })}259+ >260+ {busy === "recoverable" ? "Creating…" : "Create account-recovery passkey"}261+ </button>262+ )}263+ <button264+ type="button"265+ disabled={disabled}266+ onClick={() => {267+ if (phrase) {268+ setPhrase(null);269+ return;270+ }271+ run("reveal", async () => {272+ await confirmPhraseAccess();273+ setPhrase(await revealRecoveryPhrase());274+ });275+ }}276+ >277+ {busy === "reveal" ? "Revealing…" : phrase ? "Hide phrase" : "Show recovery phrase"}278+ </button>279+ {!session.phraseGuarded && (280+ <button281+ type="button"282+ class="account-secondary"283+ disabled={disabled}284+ onClick={() =>285+ run("protect", async () => {286+ setUnguarded(!(await createBackupPasskey()));287+ return readAccountSession();288+ })}289+ >290+ {busy === "protect" ? "Setting up…" : "Add local phrase-reveal guard"}291+ </button>292+ )}293+ <button294+ type="button"295+ class="account-secondary"296+ disabled={disabled}297+ onClick={() => {298+ setPhrase(null);299+ run("stop", stopSyncBackup);300+ }}301+ >302+ {busy === "stop" ? "Stopping…" : "Stop syncing"}303+ </button>304+ </div>305+ {phraseBlock}306+ {error && <p class="account-error" role="alert">{error}</p>}307+ </section>308+ );309+ }310+311+ return (312+ <section class="account account-out" aria-labelledby="account-title">313+ <header>314+ <p class="eyebrow">Account</p>315+ <h2 id="account-title">Back up & sync</h2>316+ </header>317+ <p>318+ You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it319+ up to sync across your devices and recover it if this one is lost. Your existing data comes320+ along. A recoverable passkey is the quick return path; the recovery phrase remains the321+ portable bearer-secret fallback.322+ </p>323+ <div class="account-actions">324+ {!pendingEnable && (325+ <button326+ type="button"327+ disabled={disabled}328+ onClick={() =>329+ run("enable", async () => {330+ try {331+ await createRecoverablePasskeyBackup();332+ setUnguarded(false);333+ } catch (cause) {334+ if (!isRecoverablePasskeyError(cause) || cause.code !== "unsupported") {335+ throw cause;336+ }337+ setUnguarded(true);338+ }339+ setPhrase(await revealRecoveryPhrase());340+ setPendingEnable(true);341+ })}342+ >343+ {busy === "enable" ? "Backing up…" : "Back up & enable sync"}344+ </button>345+ )}346+ </div>347+ {restoreBlock}348+ {phraseBlock}349+ {pendingEnable && (350+ <div class="account-actions">351+ <p class="account-note">352+ Sync starts once you confirm the phrase is saved — the page reloads and the phrase will353+ not be shown again automatically.354+ </p>355+ <button356+ type="button"357+ disabled={disabled}358+ onClick={() => run("enable", enableSyncBackup)}359+ >360+ {busy === "enable" ? "Enabling sync…" : "I saved my phrase — enable sync"}361+ </button>362+ </div>363+ )}364+ {error && <p class="account-error" role="alert">{error}</p>}365+ </section>366+ );367+}@@ -0,0 +1,119 @@
1+import { useState } from "preact/hooks";2+import { settleUiMutation } from "@nzip/lofi";3+import {4+ type BootProgress,5+ useBootProgress,6+ usePendingWrites,7+ useSyncStatus,8+} from "@nzip/lofi/preact";9+import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";10+11+// A cold first visit waits on the engine download, not on storage; name the12+// wait it is actually in, with byte progress while the download runs.13+function loadingLabel(boot: BootProgress): string {14+ if (boot.phase !== "downloading") return "Opening persistent storage…";15+ const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);16+ return boot.totalBytes === null17+ ? `Downloading the app · ${mb(boot.loadedBytes)} MB…`18+ : `Downloading the app · ${mb(boot.loadedBytes)} of ${mb(boot.totalBytes)} MB…`;19+}20+21+/**22+ * Author-owned starter UI. One island over one table: add a row through a23+ * verb with effects, list rows with per-row sync badges, and toggle a24+ * boolean. Replace it with your own schema and components — the25+ * package-owned runtime stays the same.26+ */27+export default function TaskList() {28+ const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();29+ const notice = useTaskNotice();30+ const pending = usePendingWrites();31+ const boot = useBootProgress();32+ const [text, setText] = useState("");33+34+ return (35+ <section id="tasks" class="island" data-island="tasks">36+ <header>37+ <p class="eyebrow">Local-first</p>38+ <h2>Example Island: Tasks</h2>39+ </header>40+ <form41+ onSubmit={(event) => {42+ event.preventDefault();43+ const next = text.trim();44+ if (!next) return;45+ setText("");46+ // The verb's handle is thenable: settled here at saved, while the47+ // sync fate and its effects continue in the background.48+ void settleUiMutation(create(next));49+ }}50+ >51+ <label for="new-task">New task</label>52+ <div class="composer">53+ <input54+ id="new-task"55+ value={text}56+ onInput={(event) => setText(event.currentTarget.value)}57+ autocomplete="off"58+ />59+ <button type="submit">Add</button>60+ </div>61+ </form>62+ <p class="state" role="status">63+ {status === "loading" && loadingLabel(boot)}64+ {status === "error" &&65+ `${failureKind === "read" ? "Read failed" : "Write failed"}: ${error}`}66+ {status === "ready" && `${tasks.length} item(s) · ${67+ durability === "global"68+ ? "synced to your account"69+ : durability === "local"70+ ? "saved on this device"71+ : durability === "failed"72+ ? "write declined"73+ : "ready"74+ }`}75+ </p>76+ {pending.count > 0 && (77+ <p class="state" data-pending-writes={pending.count}>78+ {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync79+ </p>80+ )}81+ {notice && (82+ <p class="state" role="status" data-notice={notice.kind}>83+ {notice.text}84+ </p>85+ )}86+ <ul aria-label="Tasks">87+ {tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}88+ </ul>89+ </section>90+ );91+}92+93+function TaskItem(94+ { task, setCompleted }: {95+ task: Task;96+ setCompleted: (id: string, completed: boolean) => PromiseLike<unknown>;97+ },98+) {99+ const syncStatus = useSyncStatus(task);100+ return (101+ <li class={task.completed ? "task task-complete" : "task"}>102+ <label class="task-toggle">103+ <input104+ type="checkbox"105+ checked={task.completed}106+ aria-label={`Complete ${task.text}`}107+ onChange={(event) =>108+ void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}109+ />110+ <span>{task.text}</span>111+ </label>112+ {syncStatus !== "synced" && (113+ <span class="state" data-sync-status={syncStatus}>114+ {syncStatus === "waiting" ? "waiting to sync" : "declined"}115+ </span>116+ )}117+ </li>118+ );119+}@@ -0,0 +1,109 @@
1+import { useCallback, useEffect, useState } from "preact/hooks";2+import type { RowOf, WriteHandle } from "@nzip/lofi";3+import { useLiveQuery, useWrite } from "@nzip/lofi/preact";4+import { s } from "@nzip/lofi/schema";5+import { app } from "../app.ts";6+7+/**8+ * Author-owned example. Tables are the nouns of your app; verbs are what9+ * happens to them. This module declares two verbs over the `tasks` table —10+ * `addTask` carries effects that run when the write's sync fate settles, and11+ * `setTaskCompleted` is a plain verb — then wraps them in a domain hook.12+ * Replace `tasks` and the verbs below with your own model; framework runtime13+ * code remains in `@nzip/lofi`.14+ */15+const tasksTable = app.schema.tasks;16+17+/** The row type comes straight from the declared schema. */18+export type Task = RowOf<typeof tasksTable>;19+20+/** A one-line consequence or compensation surfaced to the UI. */21+export type TaskNotice = { kind: "synced" | "rejected"; text: string };22+23+// A tiny author-owned notice channel: effect handlers run outside any24+// component, so they publish through module state and hooks subscribe.25+let notice: TaskNotice | null = null;26+const noticeListeners = new Set<() => void>();27+28+function publishNotice(next: TaskNotice | null): void {29+ notice = next;30+ for (const listener of [...noticeListeners]) listener();31+}32+33+/**34+ * The verb call sites use. Its effect units are declared once, here: the35+ * consequence runs when the store confirms the task, the compensation runs if36+ * a stale-policy write is denied — even if the app restarted in between.37+ */38+export const addTask = s.mutation("addTask", s.insert(tasksTable), {39+ effects: [s.log("task-added")],40+ onSynced: (task) => {41+ publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });42+ },43+ onRejected: (task) => {44+ // The engine already rolled the denied row back out of local reads; this45+ // compensates what the user was told.46+ publishNotice({47+ kind: "rejected",48+ text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,49+ });50+ },51+});52+53+/** Toggling completion is a plain verb: no consequences, same lifecycle. */54+export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));55+56+/** Subscribes to the latest effect notice; `null` until one is published. */57+export function useTaskNotice(): TaskNotice | null {58+ const [current, setCurrent] = useState<TaskNotice | null>(notice);59+ useEffect(() => {60+ const listener = () => setCurrent(notice);61+ noticeListeners.add(listener);62+ listener();63+ return () => void noticeListeners.delete(listener);64+ }, []);65+ return current;66+}67+68+export function useTasks() {69+ const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);70+ const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);71+ const [writeError, setWriteError] = useState<string | null>(null);72+ const write = useWrite(lastWrite);73+74+ const create = useCallback((text: string) => {75+ // The verb returns a WriteHandle: awaiting it means saved on this device;76+ // `.synced` and the declared effects follow the store's confirmation.77+ const handle = addTask({ text, completed: false, createdAt: new Date() });78+ setLastWrite(handle);79+ setWriteError(null);80+ handle.saved.then(undefined, (error) => {81+ setWriteError(error instanceof Error ? error.message : String(error));82+ });83+ return handle;84+ }, []);85+ const setCompleted = useCallback(86+ (id: string, completed: boolean) => setTaskCompleted(id, { completed }),87+ [],88+ );89+90+ const durability = write.stage === "synced"91+ ? "global" as const92+ : write.stage === "saved" || write.stage === "syncing"93+ ? "local" as const94+ : write.stage === "rejected"95+ ? "failed" as const96+ : "none" as const;97+98+ return {99+ status: query.status === "error" || writeError !== null ? "error" as const : query.status,100+ error: query.error ?? writeError,101+ durability,102+ lastWriteStage: write.stage,103+ lastWriteReason: write.reason,104+ tasks: query.rows,105+ failureKind: query.status === "error" ? "read" as const : "write" as const,106+ create,107+ setCompleted,108+ };109+}@@ -0,0 +1,9 @@
1+import { s } from "@nzip/lofi/schema";2+import { app } from "./schema.ts";3+4+export default s.definePermissions(app, ({ policy, session }) => {5+ policy.tasks.allowInsert.always();6+ policy.tasks.allowRead.where({ $createdBy: session.user_id });7+ policy.tasks.allowUpdate.where({ $createdBy: session.user_id });8+ policy.tasks.allowDelete.where({ $createdBy: session.user_id });9+});@@ -0,0 +1,14 @@
1+import { s } from "@nzip/lofi/schema";2+3+// Author-owned schema, declared through the lofi schema surface; UI islands4+// consume it only through public @nzip/lofi seams (see5+// tests/author-boundary_test.ts).6+const schema = {7+ tasks: s.table({8+ text: s.string(),9+ completed: s.boolean(),10+ createdAt: s.timestamp(),11+ }),12+};13+14+export const app = s.defineApp(schema);@@ -0,0 +1,125 @@
1+import type { VirtualAuthenticatorHandle } from "@nzip/lofi/testing";2+3+/**4+ * A worked example of the `@nzip/lofi/testing` WebAuthn helper: install a CDP5+ * virtual authenticator and drive a device-credential enroll -> authenticate6+ * round-trip against the running app.7+ *8+ * This is an opt-in browser gate, not part of `deno task test`:9+ * 1. Serve the app on a stable origin — for example `deno task build` followed10+ * by `deno task preview`, or `deno task dev`. Enrollment is gated on a11+ * stable RP-ID, so add the served hostname to `credentialOrigins` in12+ * `src/app.ts` (127.0.0.1 is treated as local-only and refused).13+ * 2. Point the test at that URL and run it directly:14+ * LOFI_E2E_BASE_URL=https://app.example.com/ deno test -A tests/auth_e2e_test.ts15+ *16+ * Without LOFI_E2E_BASE_URL (or without Chromium installed) it skips, so the17+ * default suite stays fast and never launches a browser.18+ *19+ * NOTE: CDP virtual authenticators do not model the WebAuthn PRF extension, so20+ * this example covers enroll/authenticate only. PRF derivation is feature-21+ * detected in the runtime and must be validated on a real device.22+ */23+24+function e2eBaseURL(): string | undefined {25+ // The default suite runs without --allow-env; treat a denied read as "skip".26+ try {27+ return Deno.env.get("LOFI_E2E_BASE_URL") || undefined;28+ } catch {29+ return undefined;30+ }31+}32+33+// Runs inside the browser page, so it may only touch WebAuthn DOM APIs. A minimal34+// inline enroll -> authenticate round-trip against the page's own origin (its35+// RP-ID); returns the base64url credential id observed on both operations.36+const roundTripCredential = async (37+ rpId: string,38+): Promise<{ enrolled: string; asserted: string }> => {39+ const toBase64Url = (buffer: ArrayBuffer): string => {40+ let binary = "";41+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);42+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");43+ };44+ const randomBytes = (length: number) => crypto.getRandomValues(new Uint8Array(length));45+46+ const created = await navigator.credentials.create({47+ publicKey: {48+ rp: { id: rpId, name: "lofi auth example" },49+ user: { id: randomBytes(16), name: "example", displayName: "example" },50+ challenge: randomBytes(32),51+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],52+ authenticatorSelection: {53+ residentKey: "required",54+ requireResidentKey: true,55+ userVerification: "required",56+ },57+ attestation: "none",58+ timeout: 60_000,59+ },60+ }) as (PublicKeyCredential | null);61+ if (!created) throw new Error("enrollment returned no credential");62+63+ const asserted = await navigator.credentials.get({64+ publicKey: {65+ rpId,66+ challenge: randomBytes(32),67+ userVerification: "required",68+ timeout: 60_000,69+ },70+ }) as (PublicKeyCredential | null);71+ if (!asserted) throw new Error("authentication returned no credential");72+73+ return {74+ enrolled: toBase64Url(created.rawId),75+ asserted: toBase64Url(asserted.rawId),76+ };77+};78+79+Deno.test("a virtual authenticator drives an enroll -> authenticate round-trip", async () => {80+ const baseURL = e2eBaseURL();81+ if (!baseURL) {82+ console.log(83+ "skipping auth example; set LOFI_E2E_BASE_URL to a stable-origin deployment to run it",84+ );85+ return;86+ }87+88+ // Imported lazily so the default suite never loads Playwright.89+ const { chromium } = await import("npm:playwright@1.61.1");90+ const { BrowserUnavailableError, withVirtualAuthenticator } = await import("@nzip/lofi/testing");91+92+ let browser;93+ try {94+ browser = await chromium.launch({ headless: true });95+ } catch (error) {96+ // Match the fixture's missing-Chromium classification and skip cleanly.97+ const message = error instanceof Error ? error.message : String(error);98+ if (99+ /executable doesn't exist|browser executable|playwright install|failed to launch/i.test(100+ message,101+ )102+ ) {103+ console.log(new BrowserUnavailableError({ cause: error }).message);104+ return;105+ }106+ throw error;107+ }108+109+ let authenticator: VirtualAuthenticatorHandle | undefined;110+ try {111+ const context = await browser.newContext({ baseURL });112+ const page = await context.newPage();113+ authenticator = await withVirtualAuthenticator(page);114+ await page.goto(baseURL, { waitUntil: "domcontentloaded" });115+116+ const rpId = new URL(baseURL).hostname;117+ const result = await page.evaluate(roundTripCredential, rpId);118+ if (result.enrolled !== result.asserted) {119+ throw new Error("the authenticated credential id must match the enrolled id");120+ }121+ } finally {122+ await authenticator?.dispose();123+ await browser.close();124+ }125+});@@ -0,0 +1,65 @@
1+const test = (globalThis as unknown as {2+ Deno: { test(name: string, body: () => void | Promise<void>): void };3+}).Deno.test;4+5+// Every author UI file is scanned with the full rule set — no exceptions, so6+// a violation cannot hide in a file the loop forgot to list.7+const authorUiFiles = [8+ new URL("../src/pages/index.astro", import.meta.url),9+ new URL("../src/layouts/Shell.astro", import.meta.url),10+ new URL("../src/islands/AccountGate.tsx", import.meta.url),11+ new URL("../src/islands/TaskList.tsx", import.meta.url),12+ new URL("../src/islands/use-tasks.ts", import.meta.url),13+];14+15+// schema.ts and permissions.ts declare data through @nzip/lofi/schema; the16+// full rule set applies to them, raw Jazz imports included.17+const schemaSurfaceFiles = [18+ new URL("../src/schema.ts", import.meta.url),19+ new URL("../src/permissions.ts", import.meta.url),20+];21+22+const packageConsumers = [23+ new URL("../src/app.ts", import.meta.url),24+ ...authorUiFiles,25+ ...schemaSurfaceFiles,26+];27+28+const forbidden = [29+ { pattern: /jazz-tools/, name: "raw Jazz import" },30+ { pattern: /(?:Shared|Service|Web)?Worker\s*\(/, name: "worker construction" },31+ { pattern: /(?:wss?|https?):\/\//, name: "transport URL" },32+ { pattern: /workbox/i, name: "Workbox configuration" },33+ { pattern: /navigator\.|globalThis\.(?:isSecureContext|SharedWorker)/, name: "browser branch" },34+];35+36+test("author source consumes public lofi package seams and hides plumbing from product UI", async () => {37+ for (38+ const [path, name] of [39+ ["../src/_lofi", "vendored src/_lofi runtime"],40+ ["../public/sw.js", "source service worker"],41+ ["../astro.config.ts", "checked-in Astro configuration"],42+ ] as const43+ ) {44+ try {45+ await Deno.stat(new URL(path, import.meta.url));46+ throw new Error(`generated project still contains ${name}`);47+ } catch (error) {48+ if (!(error instanceof Deno.errors.NotFound)) throw error;49+ }50+ }51+ for (const file of packageConsumers) {52+ const source = await Deno.readTextFile(file);53+ if (/_lofi\//.test(source)) {54+ throw new Error(`${file.pathname} imports vendored framework code`);55+ }56+ }57+ for (const file of [...authorUiFiles, ...schemaSurfaceFiles]) {58+ const source = await Deno.readTextFile(file);59+ for (const rule of forbidden) {60+ if (rule.pattern.test(source)) {61+ throw new Error(`${file.pathname} exposes forbidden ${rule.name}`);62+ }63+ }64+ }65+});@@ -0,0 +1,138 @@
1+import type { VirtualAuthenticatorHandle } from "@nzip/lofi/testing";2+3+/**4+ * A real-browser account journey: rows written local-only must survive the5+ * election to back up and sync (the runtime copies them into the managed6+ * namespace during the reload), and the phrase-reveal guard must accept only7+ * its enrolled passkey.8+ *9+ * This is an opt-in browser gate, not part of `deno task test`:10+ * 1. Configure managed sync (JAZZ_APP_ID + JAZZ_SERVER_URL in `.env`). The11+ * sync server does NOT need to be reachable — the migration completes at12+ * local durability, which is exactly what this test pins down.13+ * 2. Serve the app on a hostname a passkey RP-ID can bind to — `localhost`14+ * works: `deno task build && deno task preview`, or `deno task dev`.15+ * 3. Point the test at that URL and run it directly:16+ * LOFI_E2E_BASE_URL=http://localhost:4321/ deno test -A tests/backup_migration_e2e_test.ts17+ *18+ * Without LOFI_E2E_BASE_URL (or without Chromium installed) it skips, so the19+ * default suite stays fast and never launches a browser.20+ */21+22+function e2eBaseURL(): string | undefined {23+ // The default suite runs without --allow-env; treat a denied read as "skip".24+ try {25+ return Deno.env.get("LOFI_E2E_BASE_URL") || undefined;26+ } catch {27+ return undefined;28+ }29+}30+31+// Runs inside the browser page: the status line reports "N item(s) …" once the32+// local store has hydrated.33+const taskListReady = () => {34+ const status = document.querySelector('[data-island="tasks"] [role="status"]');35+ return status !== null && /item\(s\)/.test(status.textContent ?? "");36+};37+38+Deno.test("local rows survive sync election and the phrase guard stays pinned", async () => {39+ const baseURL = e2eBaseURL();40+ if (!baseURL) {41+ console.log(42+ "skipping backup-migration journey; set LOFI_E2E_BASE_URL to a sync-configured deployment",43+ );44+ return;45+ }46+47+ // Imported lazily so the default suite never loads Playwright.48+ const { chromium } = await import("npm:playwright@1.61.1");49+ const { BrowserUnavailableError, waitForReady, withVirtualAuthenticator } = await import(50+ "@nzip/lofi/testing"51+ );52+53+ let browser;54+ try {55+ browser = await chromium.launch({ headless: true });56+ } catch (error) {57+ const message = error instanceof Error ? error.message : String(error);58+ if (59+ /executable doesn't exist|browser executable|playwright install|failed to launch/i.test(60+ message,61+ )62+ ) {63+ console.log(new BrowserUnavailableError({ cause: error }).message);64+ return;65+ }66+ throw error;67+ }68+69+ let authenticator: VirtualAuthenticatorHandle | undefined;70+ try {71+ const context = await browser.newContext({ baseURL });72+ const page = await context.newPage();73+ authenticator = await withVirtualAuthenticator(page);74+ await page.goto(baseURL, { waitUntil: "domcontentloaded" });75+ await waitForReady(page, taskListReady, undefined, { description: "task list ready" });76+77+ // Rows written while the account is still local-only.78+ for (const text of ["survives migration", "second local row"]) {79+ await page.fill("#new-task", text);80+ await page.press("#new-task", "Enter");81+ await page.locator(".task", { hasText: text }).first().waitFor({ timeout: 15_000 });82+ }83+84+ // Two-step backup: reveal the phrase, then confirm it is saved. The85+ // confirmation reloads the document into the managed namespace.86+ const enable = page.getByRole("button", { name: "Back up & enable sync" });87+ try {88+ await enable.waitFor({ timeout: 15_000 });89+ } catch {90+ throw new Error(91+ "the backup gate never rendered — configure JAZZ_APP_ID and JAZZ_SERVER_URL in .env",92+ );93+ }94+ await enable.click();95+ const phraseWords = page.locator('[aria-label="Recovery phrase"] li');96+ await phraseWords.first().waitFor({ timeout: 30_000 });97+ if (await phraseWords.count() !== 24) {98+ throw new Error("the revealed recovery phrase must contain 24 words");99+ }100+ await Promise.all([101+ page.waitForNavigation({ waitUntil: "domcontentloaded" }),102+ page.getByRole("button", { name: "I saved my phrase — enable sync" }).click(),103+ ]);104+ await waitForReady(page, taskListReady, undefined, { description: "managed runtime ready" });105+106+ // The core regression: every local-only row is visible after the election,107+ // with no sync server required — the migration settles at local tier.108+ await page.getByRole("heading", { name: "Backed up & syncing" }).waitFor({ timeout: 30_000 });109+ for (const text of ["survives migration", "second local row"]) {110+ await page.locator(".task", { hasText: text }).first().waitFor({ timeout: 15_000 });111+ }112+113+ // Enroll the phrase-reveal guard, then reveal through the pinned ceremony114+ // while the earlier recoverable-backup passkey is still present as a decoy115+ // resident credential for the same RP ID.116+ await page.getByRole("button", { name: "Add local phrase-reveal guard" }).click();117+ await page.getByRole("button", { name: "Add local phrase-reveal guard" }).waitFor({118+ state: "detached",119+ timeout: 30_000,120+ });121+ await page.getByRole("button", { name: "Show recovery phrase" }).click();122+ await phraseWords.first().waitFor({ timeout: 30_000 });123+ await page.getByRole("button", { name: "Hide phrase" }).click();124+125+ // Lose the device's credentials: the pinned ceremony must fail closed —126+ // never reveal the phrase — rather than accept any remaining passkey.127+ // (A no-match WebAuthn request can take up to its 60s timeout to reject.)128+ await authenticator.clearCredentials();129+ await page.getByRole("button", { name: "Show recovery phrase" }).click();130+ await page.locator(".account-error").waitFor({ timeout: 90_000 });131+ if (await phraseWords.count() !== 0) {132+ throw new Error("the phrase was revealed without the enrolled guard credential");133+ }134+ } finally {135+ await authenticator?.dispose();136+ await browser.close();137+ }138+});@@ -0,0 +1,113 @@
1+/**2+ * A real, worked example of the `@nzip/lofi/testing` browser scenario: two3+ * browser clients each add a task while offline, then reconnect and converge4+ * on both.5+ *6+ * This is an opt-in browser gate, not part of `deno task test`:7+ * 1. Configure managed sync (JAZZ_APP_ID + JAZZ_SERVER_URL in .env) with the8+ * schema deployed to that store, then serve the app — for example9+ * `deno task build` followed by `deno task preview`, or `deno task dev`.10+ * 2. Point the test at that URL — it must be `localhost`, not `127.0.0.1`,11+ * because the sync election enrolls a passkey and an IP address is not a12+ * valid WebAuthn RP ID — and run it directly:13+ * LOFI_E2E_BASE_URL=http://localhost:4321/ deno test -A tests/convergence_e2e_test.ts14+ *15+ * The primary client performs the real backup-and-sync election (with a16+ * virtual authenticator) before its state is cloned: lofi is local-only by17+ * design until the account opts in, so without the election there is nothing18+ * to converge and the gate would fail for a reason that indicts no one.19+ *20+ * Without LOFI_E2E_BASE_URL (or without Chromium installed) it skips, so the21+ * default suite stays fast and never launches a browser.22+ */23+24+import type { BrowserScenarioPeer } from "@nzip/lofi/testing";25+26+function e2eBaseURL(): string | undefined {27+ // The default suite runs without --allow-env; treat a denied read as "skip".28+ try {29+ return Deno.env.get("LOFI_E2E_BASE_URL") || undefined;30+ } catch {31+ return undefined;32+ }33+}34+35+// Runs inside the browser page, so it may only touch DOM APIs: the status line36+// reports "N item(s) …" once the local store has hydrated.37+const taskListReady = () => {38+ const status = document.querySelector('[role="status"]');39+ return status !== null && /item\(s\)/.test(status.textContent ?? "");40+};41+42+const baseURL = e2eBaseURL();43+if (baseURL === undefined) {44+ Deno.test("two clients converge on concurrent offline task edits", () => {45+ console.log(46+ "skipping convergence example; set LOFI_E2E_BASE_URL to a synced deployment to run it",47+ );48+ });49+} else {50+ // Imported lazily so the default suite never loads Playwright.51+ const { scenario, waitForReady, withVirtualAuthenticator } = await import("@nzip/lofi/testing");52+53+ const ready = (peer: BrowserScenarioPeer) =>54+ waitForReady(peer.page, taskListReady, undefined, { description: `${peer.name} ready` });55+ const addTask = async (peer: BrowserScenarioPeer, text: string) => {56+ await peer.page.fill("#new-task", text);57+ await peer.page.press("#new-task", "Enter");58+ };59+ const seeTask = (peer: BrowserScenarioPeer, text: string) =>60+ peer.page.locator(".task", { hasText: text }).first().waitFor({61+ state: "visible",62+ timeout: 15_000,63+ });64+65+ scenario.browser("two clients converge on concurrent offline task edits", {66+ baseURL,67+ // Shared identity clones the first client's state in memory after the68+ // election, so both browser contexts act as the same synced account. The69+ // election runs through the real account UI.70+ identity: {71+ mode: "shared",72+ preparePrimary: async (client) => {73+ const readyPage = () =>74+ waitForReady(client.page, taskListReady, undefined, { description: "primary ready" });75+ await readyPage();76+ await withVirtualAuthenticator(client.page);77+ await client.page.getByRole("button", { name: "Back up & enable sync" }).click();78+ await client.page.locator('[aria-label="Recovery phrase"] li').first().waitFor({79+ state: "visible",80+ timeout: 30_000,81+ });82+ await client.page.getByRole("button", { name: "I saved my phrase — enable sync" }).click();83+ await client.page.getByRole("heading", { name: "Backed up & syncing" }).waitFor({84+ state: "visible",85+ timeout: 30_000,86+ });87+ await readyPage();88+ },89+ },90+ ready,91+ // Failure artifacts stay value-free: counts and booleans only, no task text.92+ snapshot: async (peer) => ({93+ online: !peer.isOffline,94+ items: await peer.page.locator(".task").count(),95+ }),96+ artifacts: { directory: "test-results" },97+ }, async ({ alice, bob }) => {98+ await alice.offline();99+ await bob.offline();100+ await addTask(alice, "first client task");101+ await addTask(bob, "second client task");102+ await seeTask(alice, "first client task");103+ await seeTask(bob, "second client task");104+ await alice.online();105+ await bob.online();106+107+ // App-owned convergence: both tasks visible on both peers.108+ for (const peer of [alice, bob]) {109+ await seeTask(peer, "first client task");110+ await seeTask(peer, "second client task");111+ }112+ });113+}@@ -0,0 +1,23 @@
1+import type {2+ BrowserTestClient,3+ ConcurrentOfflineScenario,4+ ReadinessOptions,5+ TwoClientFixture,6+} from "@nzip/lofi/testing";7+8+function assert(condition: unknown, message: string): asserts condition {9+ if (!condition) throw new Error(message);10+}11+12+Deno.test("generated projects type-check the public local-first browser helpers", () => {13+ type TestingContract = {14+ client: BrowserTestClient;15+ fixture: TwoClientFixture;16+ readiness: ReadinessOptions;17+ scenario: ConcurrentOfflineScenario<string>;18+ };19+ const exportedNames = ["client", "fixture", "readiness", "scenario"] satisfies readonly (20+ keyof TestingContract21+ )[];22+ assert(exportedNames.length === 4, "@nzip/lofi/testing contract was not checked");23+});@@ -33,13 +33,29 @@ export const STARTER_FILES: readonly string[] = [
3333 "tests/testing-contract_test.ts",3434 ];3535 36+/**37+ * TypeScript starter files are read from a verbatim `.txt` mirror under38+ * `package/starter/` instead of the reference app itself. Publishing rewrites39+ * the specifiers inside every module in the package — which would hand40+ * generated projects direct `jsr:`/`npm:` imports that bypass their pinned41+ * import map and break their formatting — but leaves non-module files alone.42+ * The mirror is kept byte-identical to the reference app by43+ * `create_core_test.ts`.44+ */45+function starterFileUrl(relativePath: string): URL {46+ if (/\.tsx?$/.test(relativePath)) {47+ return new URL(`./starter/${relativePath}.txt`, import.meta.url);48+ }49+ return new URL(`../apps/reference/${relativePath}`, import.meta.url);50+}51+3652 /**3753 * Reads a starter file's contents, resolving it relative to the reference app.3854 * Works both from a source checkout (a `file:` URL read from disk) and from the3955 * published package (an `https:` JSR URL fetched over the network).4056 */4157 export async function readStarterFile(relativePath: string): Promise<Uint8Array> {42- const url = new URL(`../apps/reference/${relativePath}`, import.meta.url);58+ const url = starterFileUrl(relativePath);4359 if (url.protocol === "file:") return await Deno.readFile(url);4460 const response = await fetch(url);4561 if (!response.ok) {@@ -8,17 +8,17 @@
88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/icon-monochrome.svg": "db5bdb4b62cd1f4dcd72494b2f94e5aac0246cf4318e03468f121cf9339d2df9",11- "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",11+ "public/manifest.webmanifest": "d11baba1a3986975ed839dac9c82c25278e6da1a65fa3f3b99d3b23c57c2cbad",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "10d47c681ae44a3d70b790e3952e0ac361e0eeec4a433f0ff35cfe9db61a7778",15- "src/app.ts": "922d9d975d9666dbcdcc35d5de149079fcdab46d2571397df7d897f8e905e84d",14+ "README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",15+ "src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "502009c583a51a6a4b7fb4402e86ec87c61dbc85d87fa0210969144c328a7d1a",1818 "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf",1919 "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",21- "src/pages/index.astro": "884afb177743ccef0d996b8f60158f948b8bf38c966281f29564a9b4e767fa4c",21+ "src/pages/index.astro": "8a696a8154b77c34deba3919f6383dc2ed0a6af15612fd9b16f04416e3331e93",2222 "src/permissions.ts": "7a2dad8ce48816c1611c85049209f4fddef08ab332dcbd94bac4fa162fc6c1b0",2323 "src/schema.ts": "d2bff3864609433071c3a3d94dac79a56e3cdd05e7cf7640831928bba74a8b2e",2424 "src/styles/global.css": "da8594026ea8420d00c58549b9b38040b4456f75615959ca2e94997b18be1997",