clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(auth,cli): local-first accounts with recovery-phrase backup + Jazz app provisioning

Dami committed 945e1bd
Reshape identity around a local-first pit of success and add one-step sync
bootstrap.

Auth (seed-custody, static, no backend):
- First boot opens a private, on-device account instantly — no sign-in gate.
- Opt-in "Back up & sync": reveal a 24-word recovery phrase (jazz-tools/passphrase)
  and turn on sync by recreating the runtime with serverUrl. Same secret → same
  Jazz ID, so data created while local-only carries over.
- Restore from the phrase on another device.
- Remove the passkey derive-identity layer (deriveAccount/deriveAuthSecret); it
  cannot preserve a pre-existing local-only account's data. Keep auth.ts's PRF
  at-rest primitives.
- New src/_lofi/recovery.ts; rework session.ts, AccountGate, config (per-user
  sync election), runtime, and the status/inspector/diagnostics wording.

Provisioning:
- deno task jazz:provision and create --sync generate a managed Jazz app via
  the dashboard endpoint and merge JAZZ_APP_ID/JAZZ_SERVER_URL/JAZZ_ADMIN_SECRET/
  BACKEND_SECRET into .env (secrets never printed).
- New package/tooling/provision.ts + package/commands/provision.ts, wired into
  exports, starter deno.json, and create_core.

Docs: correct README Status, rewrite docs/auth-identity.md, note provisioning in
the generated README/.env. Regenerate starter snapshot. Tests: recovery_test,
provision_test; trim removed derive tests. Full suite green, lint/fmt clean,
publish dry-run clean.
commit
945e1bdcc3b573d3df8f8005fda47e36bf645b80
parent
5430d6164fb4f2cf07c5c83eaca4954598136bf0
author
Dami <dami.etoile@gmail.com>
29 files changed, +1081 -664
MREADME.md47+++--
Mapps/reference/deno.json3+
Mapps/reference/src/_lofi/DeviceStatus.tsx49++---
Mapps/reference/src/_lofi/auth.ts67+-------
Mapps/reference/src/_lofi/auth_test.ts94----------
Mapps/reference/src/_lofi/config.ts39++++-
Mapps/reference/src/_lofi/inspector.ts2+-
Mapps/reference/src/_lofi/inspector_test.ts2+-
Mapps/reference/src/_lofi/probe.ts4+-
Aapps/reference/src/_lofi/recovery.ts94++++++++++
Aapps/reference/src/_lofi/recovery_test.ts88+++++++++
Mapps/reference/src/_lofi/runtime.ts25+--
Mapps/reference/src/_lofi/session.ts212+++++++---------------
Mapps/reference/src/app.ts19+-
Mapps/reference/src/islands/AccountGate.tsx279++++++++++++++----------------
Mapps/reference/src/islands/TaskList.tsx14+-
Mapps/reference/src/islands/use-tasks.ts17+-
Mdeno.json2+
Mdocs/auth-identity.md159++++++++---------
Mpackage/commands/dev.ts4+-
Apackage/commands/provision.ts67+++++++
Mpackage/create.ts46+++++-
Mpackage/create_core.ts19++-
Mpackage/create_core_test.ts4+-
Mpackage/starter_template.ts2+
Mpackage/testdata/starter.snapshot.json34++--
Mpackage/tooling/diagnostics.ts6+-
Apackage/tooling/provision.ts209++++++++++++++++++++++
Apackage/tooling/provision_test.ts138+++++++++++++++
M README.md +32 -15
@@ -19,9 +19,19 @@ That's the whole onboarding. You now have an installable, offline-capable local-
1919 write, reload it, go offline, and the UI keeps working because it reads from durable local storage,2020 not the network.2121 22+Want sync and account backup from the start? Scaffold with `--sync`:23+24+```sh25+deno run -A jsr:@nzip/lofi/create --sync my-app26+```27+28+It provisions a managed Jazz app and writes the project's `.env`, so the app can offer synced,29+recoverable accounts immediately.30+2231 > Requires **Deno 2.9+**, the only global runtime. With no `.env` the app runs local-only (stated23-> explicitly at boot); for managed sync, copy `.env.example` and set the public `JAZZ_APP_ID` /24-> `JAZZ_SERVER_URL` pair. Server-only secrets never reach client output.32+> explicitly at boot). For managed sync + backup, run `deno task jazz:provision` (or scaffold with33+> `create --sync`) to generate a Jazz app and write the `.env` — or set the public `JAZZ_APP_ID` /34+> `JAZZ_SERVER_URL` pair by hand. Server-only secrets never reach client output.2535 2636 ## What a generated project looks like2737
@@ -50,16 +60,17 @@ framework runtime; product work never needs to touch it.
5060 5161 Every generated project ships this task surface (`deno task <name>`):5262 53-| Command | What it does |54-| --------------------------------------- | ----------------------------------------------------------- |55-| `dev` | Astro dev server; prints storage, identity, and sync state. |56-| `doctor` | Value-free readiness report — no secrets, no faked claims. |57-| `test` | Deterministic local-first tests (no hand-timed sleeps). |58-| `build` | Static production build into `dist/`. |59-| `preview` | Serves the production build locally. |60-| `deploy` / `deploy:create` | Host the static build on Deno Deploy. |61-| `schema:validate` / `schema:deploy` | Validate and publish your Jazz schema. |62-| `migrations:create` / `migrations:push` | Author and push schema migrations. |63+| Command | What it does |64+| --------------------------------------- | ------------------------------------------------------------- |65+| `dev` | Astro dev server; prints storage, identity, and sync state. |66+| `doctor` | Value-free readiness report — no secrets, no faked claims. |67+| `test` | Deterministic local-first tests (no hand-timed sleeps). |68+| `build` | Static production build into `dist/`. |69+| `preview` | Serves the production build locally. |70+| `deploy` / `deploy:create` | Host the static build on Deno Deploy. |71+| `jazz:provision` | Generate a managed Jazz app and write `.env` for sync/backup. |72+| `schema:validate` / `schema:deploy` | Validate and publish your Jazz schema. |73+| `migrations:create` / `migrations:push` | Author and push schema migrations. |6374 6475 ## Testing local-first behavior6576
@@ -92,9 +103,15 @@ Version pins are deliberate: the data layer is an alpha, so every bump is a revi
92103 93104 ## Status94105 95-Early release, pre-1.0. Passkey backup and recovery are intentionally absent — the pinned Jazz alpha96-exposes a rejected credential design — so a fresh project uses a device-local identity and does not97-imply recoverability.106+Early release, pre-1.0. Identity is **local-first**: a new app opens on a private, on-device account107+with no sign-in and no network. When a managed Jazz app is configured (`deno task jazz:provision`),108+the user can elect to back up and sync that account and recover it from a 24-word **recovery109+phrase** — the same account, made portable. Electing to sync preserves everything created while110+local-only, because the account identity never changes. lofi holds no recoverable account material111+server-side: the phrase is the backup, so keeping it is the user's custody. (The passkey/PRF module112+in `src/_lofi/auth.ts` remains as an optional at-rest-encryption primitive; deriving the account113+_from_ a credential was removed because it cannot preserve a pre-existing local-only account's114+data.)98115 99116 ---100117
M apps/reference/deno.json +3 -0
M apps/reference/src/_lofi/DeviceStatus.tsx +17 -32
@@ -1,5 +1,4 @@
11 import { useEffect, useState } from "preact/hooks";2-import { referenceApp } from "../app.ts";32 import { serverUrl } from "./config.ts";43 import { useDeviceCapabilities } from "./use-device-capabilities.ts";54 import { settleUiMutation } from "./ui-mutation.ts";
@@ -33,31 +32,26 @@ export default function DeviceStatus() {
3332 3433 useEffect(() => subscribePwaState(setPwa), []);3534 useEffect(() => {36- let active = true;37- const refresh = () => {38- void readSession().then((next) => {39- if (active) setSession(next);40- });41- };35+ const refresh = () => setSession(readSession());4236 refresh();43- // Re-read after a sign-in / sign-out recreates the runtime.37+ // Re-read after electing to sync or restoring an account recreates the runtime.4438 globalThis.addEventListener(runtimeRecreatedEvent, refresh);45- return () => {46- active = false;47- globalThis.removeEventListener(runtimeRecreatedEvent, refresh);48- };39+ return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);4940 }, []);5041 5142 if (!report) return <p class="device-status">Checking device capabilities…</p>;5243 53- const passkey = referenceApp.identity === "device-passkey";5444 const synced = Boolean(serverUrl);55- const origin = session?.capability?.origin;56- const portability = !passkey57- ? "stays on this device"58- : session?.profile59- ? (session.profile.portable ? "roams across your devices" : "this device only")60- : "known once you sign in";45+ const syncState = session?.syncing46+ ? "syncing to your account"47+ : session?.syncAvailable48+ ? "available — not yet backed up"49+ : "local-only";50+ const backupState = session?.backedUp51+ ? "recovery phrase"52+ : session?.syncAvailable53+ ? "not backed up"54+ : "local-only";6155 6256 return (6357 <section class="device-status" aria-labelledby="device-status-title">
@@ -98,7 +92,7 @@ export default function DeviceStatus() {
9892 Hook in: <code>src/_lofi/config.ts</code>, <code>runtime.ts</code>9993 </p>10094 <dl>101- <Row label="Sync" value={synced ? "syncing to your account" : "local-only"} />95+ <Row label="Sync" value={syncState} />10296 <Row label="Managed server" value={synced ? "configured" : "not configured"} />10397 </dl>10498 {!synced && (
@@ -115,19 +109,10 @@ export default function DeviceStatus() {
115109 Hook in: <code>src/_lofi/auth.ts</code>, <code>session.ts</code>116110 </p>117111 <dl>118- <Row label="Identity" value={passkey ? "passkey account" : "device-local key"} />119- {passkey && (120- <Row121- label="Status"122- value={session ? (session.signedIn ? "signed in" : "signed out") : "…"}123- />124- )}112+ <Row label="Identity" value="local-first account" />113+ <Row label="Backup" value={backupState} />125114 <Row label="WebAuthn" value={available(report.webAuthn)} />126- <Row label="PRF (account derivation)" value={report.prf} />127- {passkey && origin && (128- <Row label={`Origin (${origin.rpId || "unknown"})`} value={origin.status} />129- )}130- {passkey && <Row label="Account portability" value={portability} />}115+ <Row label="PRF (at-rest key)" value={report.prf} />131116 </dl>132117 </div>133118
M apps/reference/src/_lofi/auth.ts +6 -61
@@ -427,64 +427,9 @@ export async function decryptAtRest(
427427 return new Uint8Array(plaintext);428428 }429429 430-// --- Account identity ("the key is the account") -----------------------------431-432-// A fixed salt so the same credential always derives the same account for lofi.433-// App scoping comes from the credential itself (bound to the app's origin).434-const ACCOUNT_SECRET_SALT = new TextEncoder().encode("lofi:account-secret:v1");435-436-/**437- * Derives the account secret **deterministically** from the passkey via PRF, in438- * Jazz's 32-byte base64url auth-secret format. The same portable credential439- * reconstructs the *same* account on any device — so the key is the account, and440- * the account lives wherever the key does. Nothing is stored server-side and no441- * recovery is implied: without the key, the account is gone.442- *443- * Requires a `stable` origin and a PRF-capable client; throws `origin-rejected`444- * or `prf-unavailable` otherwise (never a fabricated secret). Feed the result to445- * Jazz as the account secret (e.g. via `BrowserAuthSecretStore.saveSecret`).446- */447-export async function deriveAuthSecret(dependencies: AuthDependencies = {}): Promise<string> {448- const prfSecret = await derivePrfSecret(ACCOUNT_SECRET_SALT, dependencies);449- return await hkdfAccountSecret(prfSecret);450-}451-452-// HKDF-SHA-256 expands the raw PRF secret into Jazz's 32-byte base64url453-// auth-secret format. Kept separate so both `deriveAuthSecret` and454-// `deriveAccount` produce byte-identical secrets from the same PRF result.455-async function hkdfAccountSecret(prfSecret: Uint8Array): Promise<string> {456- const material = await crypto.subtle.importKey(457- "raw",458- prfSecret as BufferSource,459- "HKDF",460- false,461- ["deriveBits"],462- );463- const bits = await crypto.subtle.deriveBits(464- {465- name: "HKDF",466- hash: "SHA-256",467- salt: new Uint8Array(0),468- info: new TextEncoder().encode("lofi:account-secret"),469- },470- material,471- 256,472- );473- return toBase64Url(new Uint8Array(bits));474-}475-476-/** An account secret paired with the credential that unlocked it. */477-export type PasskeyAccount = { secret: string; credential: DeviceCredential };478-479-/**480- * Signs in with a passkey in **one** ceremony: derives the deterministic Jazz481- * account secret (as {@link deriveAuthSecret}) and reports the asserting482- * credential (its `rpId` and whether it is `portable`), so the UI can show which483- * key unlocked the account and whether it roams. Same guarantees and failures as484- * {@link deriveAuthSecret}: a `stable`/`local-only` origin and a PRF result, or a485- * thrown `origin-rejected` / `prf-unavailable` — never a fabricated secret.486- */487-export async function deriveAccount(dependencies: AuthDependencies = {}): Promise<PasskeyAccount> {488- const { prfSecret, credential } = await prfAssertion(ACCOUNT_SECRET_SALT, dependencies);489- return { secret: await hkdfAccountSecret(prfSecret), credential };490-}430+// Account identity is not derived from a credential in lofi. The account is a431+// local-first secret (see `session.ts`), backed up by a recovery phrase and,432+// optionally, protected at rest by the PRF key above. Deriving the *account*433+// from a passkey was removed: it cannot preserve a pre-existing local-only434+// account's data, since a derived key is a different account. This module stays435+// a device-credential + at-rest-key primitive, nothing more.
M apps/reference/src/_lofi/auth_test.ts +0 -94
@@ -3,9 +3,7 @@ import {
33 AuthError,44 classifyCredentialOrigin,55 decryptAtRest,6- deriveAccount,76 deriveAtRestKey,8- deriveAuthSecret,97 derivePrfSecret,108 encryptAtRest,119 enrollDeviceCredential,
@@ -263,95 +261,3 @@ test("enrollDeviceCredential reports portable false without the BE flag or authe
263261 });264262 assert(missing.portable === false, "an unreadable credential must default to not portable");265263 });266-267-test("deriveAuthSecret derives a deterministic Jazz auth-secret from the PRF result", async () => {268- const first = crypto.getRandomValues(new Uint8Array(32));269- const credentials = fakeCredentials({270- get: () =>271- Promise.resolve({272- rawId: rawIdBuffer(),273- getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),274- } as unknown as Credential),275- });276- const secret = await deriveAuthSecret({ credentials, rpId: "app.example.com" });277- assert(secret.length === 43, "a 32-byte base64url secret must be 43 characters");278- assert(/^[A-Za-z0-9_-]+$/.test(secret), "the secret must be base64url with no padding");279-280- const decoded = atob(secret.replaceAll("-", "+").replaceAll("_", "/"));281- assert(decoded.length === 32, "the secret must decode to 32 bytes");282-283- const again = await deriveAuthSecret({ credentials, rpId: "app.example.com" });284- assert(again === secret, "the same PRF input must derive the same secret");285-286- const other = crypto.getRandomValues(new Uint8Array(32));287- const otherCredentials = fakeCredentials({288- get: () =>289- Promise.resolve({290- rawId: rawIdBuffer(),291- getClientExtensionResults: () => ({ prf: { results: { first: other.buffer } } }),292- } as unknown as Credential),293- });294- const otherSecret = await deriveAuthSecret({295- credentials: otherCredentials,296- rpId: "app.example.com",297- });298- assert(otherSecret !== secret, "a different PRF input must derive a different secret");299-});300-301-test("deriveAuthSecret throws prf-unavailable when the client returns no PRF result", async () => {302- const credentials = fakeCredentials({303- get: () =>304- Promise.resolve({305- rawId: rawIdBuffer(),306- getClientExtensionResults: () => ({}),307- } as unknown as Credential),308- });309- await expectAuthError(310- () => deriveAuthSecret({ credentials, rpId: "app.example.com" }),311- "prf-unavailable",312- );313-});314-315-test("deriveAccount returns the account secret and the asserting credential", async () => {316- const first = crypto.getRandomValues(new Uint8Array(32));317- const credentials = fakeCredentials({318- get: () =>319- Promise.resolve({320- rawId: rawIdBuffer(),321- response: { getAuthenticatorData: () => authenticatorData(true) },322- getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),323- } as unknown as Credential),324- });325- const account = await deriveAccount({ credentials, rpId: "app.example.com" });326-327- // The secret must equal what deriveAuthSecret derives from the same PRF result,328- // so a device that enrolled via one path signs in via the other to one account.329- const viaAuthSecret = await deriveAuthSecret({330- credentials: fakeCredentials({331- get: () =>332- Promise.resolve({333- rawId: rawIdBuffer(),334- getClientExtensionResults: () => ({ prf: { results: { first: first.buffer } } }),335- } as unknown as Credential),336- }),337- rpId: "app.example.com",338- });339- assert(account.secret === viaAuthSecret, "deriveAccount must match deriveAuthSecret's secret");340- assert(account.credential.id === "AQIDBA", "the credential id must be the base64url rawId");341- assert(account.credential.rpId === "app.example.com", "the credential must carry the rpId");342- assert(account.credential.portable === true, "the BE flag must surface as portable");343-});344-345-test("deriveAccount throws prf-unavailable when the client returns no PRF result", async () => {346- const credentials = fakeCredentials({347- get: () =>348- Promise.resolve({349- rawId: rawIdBuffer(),350- getClientExtensionResults: () => ({}),351- } as unknown as Credential),352- });353- await expectAuthError(354- () => deriveAccount({ credentials, rpId: "app.example.com" }),355- "prf-unavailable",356- );357-});
M apps/reference/src/_lofi/config.ts +38 -1
@@ -11,10 +11,47 @@ const configuredServerUrl = typeof __LOFI_JAZZ_SERVER_URL__ === "string"
1111 export const appId = configuredAppId || LOCAL_APP_ID;1212 export const serverUrl = configuredServerUrl || undefined;1313 14+/** Whether this deployment has a managed Jazz app, so sync/backup is possible. */15+export const syncAvailable = Boolean(serverUrl);16+17+const syncElectionKey = `lofi:sync-elected:${appId}`;18+19+/**20+ * Whether the user on this device has elected to back up and sync this account.21+ * First boot is local-only; the account only reaches the network once the user22+ * opts in (see `session.ts`), so nothing leaves the device by default.23+ */24+export function syncElected(): boolean {25+ if (!syncAvailable || typeof localStorage === "undefined") return false;26+ try {27+ return localStorage.getItem(syncElectionKey) === "1";28+ } catch {29+ return false;30+ }31+}32+33+/** Records (or clears) the sync election. Ignored when no Jazz app is configured. */34+export function setSyncElected(elected: boolean): void {35+ if (!syncAvailable || typeof localStorage === "undefined") return;36+ try {37+ if (elected) localStorage.setItem(syncElectionKey, "1");38+ else localStorage.removeItem(syncElectionKey);39+ } catch {40+ // A private-mode storage failure must not break account setup.41+ }42+}43+44+/** Whether writes actually replicate right now: a Jazz app is configured *and* elected. */45+export function syncing(): boolean {46+ return syncAvailable && syncElected();47+}48+1449 export function databaseConfig(secret: string): DbConfig {1550 return {1651 appId,17- ...(serverUrl ? { serverUrl } : {}),52+ // The same local-first secret opens the same account whether or not a server53+ // is attached, so electing to sync later preserves all existing data.54+ ...(syncing() && serverUrl ? { serverUrl } : {}),1855 secret,1956 driver: { type: "persistent", dbName: `${referenceApp.databaseName}-${appId}` },2057 };
M apps/reference/src/_lofi/inspector.ts +1 -1
M apps/reference/src/_lofi/inspector_test.ts +1 -1
M apps/reference/src/_lofi/probe.ts +2 -2
A apps/reference/src/_lofi/recovery.ts +94 -0
@@ -0,0 +1,94 @@
1+/**2+ * Recovery-phrase backup for the local-first account.3+ *4+ * A lofi account is a 32-byte secret held on the device. That is enough to work5+ * offline forever, but it means a cleared browser or a second device has no way6+ * back in. A recovery phrase is the honest, portable backup: the *same* secret,7+ * encoded as 24 English words the user can write down. Typing them back on any8+ * device reconstructs the identical account — so the data that synced up comes9+ * straight back down, decrypted, under the same identity.10+ *11+ * This is a thin, feature-detected wrapper over Jazz's `RecoveryPhrase`12+ * (`jazz-tools/passphrase`). It makes no server or escrow claim: the phrase is13+ * the account, so whoever holds it holds the account, and losing every copy of14+ * both the device and the phrase loses it for good.15+ *16+ * @module17+ */18+19+import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";20+21+/** The number of words in a lofi recovery phrase. */22+export const RECOVERY_PHRASE_WORDS = 24;23+24+/** A precise, non-leaking failure reason for a recovery-phrase operation. */25+export class RecoveryError extends Error {26+ override readonly name = "RecoveryError";27+ readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";28+ constructor(code: RecoveryError["code"], message?: string) {29+ super(message ?? `Recovery phrase operation failed: ${code}.`);30+ this.code = code;31+ }32+}33+34+const MESSAGES: Record<RecoveryError["code"], string> = {35+ "invalid-length":36+ `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,37+ "invalid-word": "One of the words is not in the recovery word list — check your spelling.",38+ "invalid-checksum":39+ "That phrase is not a valid recovery phrase — re-check the words and their order.",40+ "invalid-secret": "The account secret could not be encoded as a recovery phrase.",41+};42+43+function mapError(error: unknown): RecoveryError {44+ if (error instanceof RecoveryError) return error;45+ if (error instanceof RecoveryPhraseError) {46+ const code = error.code as RecoveryError["code"];47+ return new RecoveryError(code, MESSAGES[code] ?? undefined);48+ }49+ return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);50+}51+52+/**53+ * Encodes an account secret as its {@link RECOVERY_PHRASE_WORDS}-word recovery54+ * phrase. The phrase *is* the secret — show it once for the user to write down,55+ * never persist it.56+ */57+export function toRecoveryPhrase(secret: string): string {58+ try {59+ return RecoveryPhrase.fromSecret(secret);60+ } catch (error) {61+ throw mapError(error);62+ }63+}64+65+// Collapses the many ways a person types a phrase (extra spaces, stray casing,66+// smart punctuation) down to the canonical space-separated lowercase words the67+// decoder expects, so an honest phrase is not rejected over formatting.68+function normalizePhrase(input: string): string {69+ return input70+ .normalize("NFKD")71+ .replace(/[^A-Za-z\s]/g, " ")72+ .trim()73+ .replace(/\s+/g, " ")74+ .toLowerCase();75+}76+77+/**78+ * Decodes a user-entered recovery phrase back into the account secret,79+ * tolerating whitespace and casing differences. Throws {@link RecoveryError}80+ * with an actionable code — never a fabricated secret — when the phrase is81+ * malformed.82+ */83+export function fromRecoveryPhrase(phrase: string): string {84+ const normalized = normalizePhrase(phrase);85+ if (normalized.length === 0) throw new RecoveryError("invalid-length");86+ if (normalized.split(" ").length !== RECOVERY_PHRASE_WORDS) {87+ throw new RecoveryError("invalid-length");88+ }89+ try {90+ return RecoveryPhrase.toSecret(normalized);91+ } catch (error) {92+ throw mapError(error);93+ }94+}
A apps/reference/src/_lofi/recovery_test.ts +88 -0
@@ -0,0 +1,88 @@
1+import {2+ fromRecoveryPhrase,3+ RECOVERY_PHRASE_WORDS,4+ RecoveryError,5+ toRecoveryPhrase,6+} from "./recovery.ts";7+import { assert } from "./test-assert.ts";8+9+const test = (globalThis as unknown as {10+ Deno: { test(name: string, body: () => void | Promise<void>): void };11+}).Deno.test;12+13+// A 32-byte base64url value, the shape of a Jazz account secret.14+function sampleSecret(seed = 3): string {15+ const bytes = new Uint8Array(32).map((_, i) => (i * 7 + seed) & 0xff);16+ let binary = "";17+ for (const byte of bytes) binary += String.fromCharCode(byte);18+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");19+}20+21+test("toRecoveryPhrase encodes an account secret as the expected number of words", () => {22+ const phrase = toRecoveryPhrase(sampleSecret());23+ assert(24+ phrase.split(" ").length === RECOVERY_PHRASE_WORDS,25+ `a recovery phrase must be ${RECOVERY_PHRASE_WORDS} words`,26+ );27+});28+29+test("a phrase round-trips back to the exact same secret", () => {30+ const secret = sampleSecret(11);31+ const restored = fromRecoveryPhrase(toRecoveryPhrase(secret));32+ assert(restored === secret, "the phrase must reconstruct the identical account secret");33+});34+35+test("distinct secrets produce distinct phrases", () => {36+ assert(37+ toRecoveryPhrase(sampleSecret(1)) !== toRecoveryPhrase(sampleSecret(2)),38+ "different accounts must not share a recovery phrase",39+ );40+});41+42+test("fromRecoveryPhrase tolerates messy whitespace and casing", () => {43+ const secret = sampleSecret(5);44+ const phrase = toRecoveryPhrase(secret);45+ const messy = ` ${phrase.toUpperCase().replaceAll(" ", " ")}\n`;46+ assert(fromRecoveryPhrase(messy) === secret, "an honest phrase must survive reformatting");47+});48+49+test("fromRecoveryPhrase rejects a wrong word count with invalid-length", () => {50+ let code: string | undefined;51+ try {52+ fromRecoveryPhrase("one two three");53+ } catch (error) {54+ if (error instanceof RecoveryError) code = error.code;55+ }56+ assert(code === "invalid-length", "too few words must raise invalid-length");57+});58+59+test("fromRecoveryPhrase rejects an empty phrase with invalid-length", () => {60+ let threw = false;61+ try {62+ fromRecoveryPhrase(" ");63+ } catch (error) {64+ threw = error instanceof RecoveryError && error.code === "invalid-length";65+ }66+ assert(threw, "an empty phrase must raise a RecoveryError");67+});68+69+test("fromRecoveryPhrase rejects a full-length but invalid phrase", () => {70+ const words = Array.from({ length: RECOVERY_PHRASE_WORDS }, () => "zzzzzz").join(" ");71+ let threw = false;72+ try {73+ fromRecoveryPhrase(words);74+ } catch (error) {75+ threw = error instanceof RecoveryError;76+ }77+ assert(threw, "unknown words or a bad checksum must raise a RecoveryError");78+});79+80+test("toRecoveryPhrase rejects a secret that is not a 32-byte base64url value", () => {81+ let threw = false;82+ try {83+ toRecoveryPhrase("too-short");84+ } catch (error) {85+ threw = error instanceof RecoveryError;86+ }87+ assert(threw, "an ill-formed secret must raise a RecoveryError, never a fake phrase");88+});
M apps/reference/src/_lofi/runtime.ts +9 -16
@@ -1,8 +1,6 @@
11 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";2-import { referenceApp } from "../app.ts";3-import { AuthError } from "./auth.ts";42 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";5-import { appId, databaseConfig, serverUrl } from "./config.ts";3+import { appId, databaseConfig, syncing } from "./config.ts";64 import { assertDurableBrowser } from "./device-capabilities.ts";75 import {86 createTableStore,
@@ -54,19 +52,14 @@ function notifyDiagnostics(state = slot()): void {
5452 for (const listener of state.diagnosticListeners) listener();5553 }5654 57-// "device-local": a random per-device secret, created on demand — identity58-// never waits. "device-passkey" (default): the passkey is the account, so the59-// secret is derived from the credential's PRF and cached. Booting the database60-// waits for that cached secret; the auth gate (see `session.ts`) runs the61-// passkey ceremony on a user gesture, saves the secret, and recreates the62-// runtime. Return boots find the cached secret and open without a ceremony.55+// Local-first: a random per-device secret, created on demand and cached — so56+// the account opens immediately on first boot, offline, with no ceremony. The57+// same secret opens the same account whether or not sync is attached, so a58+// later election to back up and sync (see `session.ts`) keeps all existing59+// data. Recovering from a phrase replaces the cached secret and recreates the60+// runtime, so the restored account's synced data opens under the same identity.6361 async function resolveAccountSecret(): Promise<string> {64- if (referenceApp.identity !== "device-passkey") {65- return await BrowserAuthSecretStore.getOrCreateSecret({ appId });66- }67- const cached = await BrowserAuthSecretStore.loadSecret({ appId });68- if (cached) return cached;69- throw new AuthError("credential-missing", "Sign in with your passkey to open your account.");62+ return await BrowserAuthSecretStore.getOrCreateSecret({ appId });7063 }7164 7265 async function createClient(state: RuntimeSlot): Promise<Db> {
@@ -105,7 +98,7 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
10598 let store = stores.get(table);10699 if (!store) {107100 store = createTableStore(db, table, state.diagnostics, {108- syncConfigured: Boolean(serverUrl),101+ syncConfigured: syncing(),109102 onDiagnosticsChange: () => notifyDiagnostics(state),110103 }) as TableStore<TableRow, unknown>;111104 stores.set(table, store);
M apps/reference/src/_lofi/session.ts +68 -144
@@ -1,13 +1,14 @@
11 /**2- * Account session for the `device-passkey` identity: the glue between the3- * WebAuthn primitive in `auth.ts` and the runtime's account secret.2+ * Account session for lofi's local-first identity: the glue between the account3+ * secret the runtime opens and the user's choice to back it up and sync.44 *5- * "The key is the account", so signing in *is* deriving the account secret from6- * the passkey (a user-gesture WebAuthn ceremony), caching it, and recreating the7- * runtime so data opens. Signing out forgets the cached secret on this device —8- * it never deletes the passkey and makes no recovery claim. When identity is9- * `device-local`, there is nothing to sign into and {@link readSession} always10- * reports signed-in.5+ * First boot is **local-only** — a random per-device account opens immediately,6+ * with no ceremony and no network. From there the user can *elect* to back up7+ * and sync: revealing a recovery phrase (the portable, honest backup of the8+ * exact same account) and turning on replication to the managed Jazz app. The9+ * account identity never changes, so electing to sync preserves every write made10+ * while local-only. Restoring a phrase on a new device reconstructs the same11+ * account and its synced data flows back down.1112 *1213 * The small, honest UI that drives this lives in author space1314 * (`src/islands/AccountGate.tsx`) — this module is the framework side.
@@ -16,164 +17,87 @@
1617 */1718 1819 import { BrowserAuthSecretStore } from "jazz-tools";19-import { referenceApp } from "../app.ts";20-import { appId, serverUrl } from "./config.ts";21-import {22- type AuthCapability,23- AuthError,24- deriveAccount,25- type DeviceCredential,26- enrollDeviceCredential,27- getAuthCapability,28-} from "./auth.ts";29-import { recreateRuntime, runtimeRecreatedEvent, shutdownRuntime } from "./runtime.ts";20+import { appId, setSyncElected, syncAvailable, syncElected, syncing } from "./config.ts";21+import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";22+import { recreateRuntime, runtimeRecreatedEvent } from "./runtime.ts";3023 31-/** A signed-in identity, remembered locally so the status UI can name the key. */32-export type AccountProfile = {33- /** The relying-party id (origin hostname) the account's passkey is bound to. */34- rpId: string;35- /** Whether the passkey roams across devices (backup-eligible). */36- portable: boolean;37- /** The nickname the passkey was enrolled with. */38- label: string;39-};40-41-/** A snapshot of the account gate: what to render and what the client can do. */24+/** A snapshot of the account: what is possible and what the user has chosen. */4225 export type Session = {43- /** The configured identity model (`app.ts`). */44- identity: "device-local" | "device-passkey";45- /** Whether this identity requires a passkey sign-in before data opens. */46- requiresPasskey: boolean;47- /** Whether an account secret is available (data can open). */48- signedIn: boolean;49- /** Whether writes replicate to a managed account (cloud configured). */26+ /** Whether a managed Jazz app is configured, so backup + sync is possible at all. */27+ syncAvailable: boolean;28+ /** Whether the user has elected to back up and sync this account on this device. */29+ backedUp: boolean;30+ /** Whether writes replicate right now (available *and* elected). */5031 syncing: boolean;51- /** Device/browser/origin capability, or `null` for `device-local`. */52- capability: AuthCapability | null;53- /** The remembered account on this device, if any. */54- profile: AccountProfile | null;5532 };5633 57-const profileKey = `lofi:account-profile:${appId}`;58-59-function loadProfile(): AccountProfile | null {60- if (typeof localStorage === "undefined") return null;61- try {62- const raw = localStorage.getItem(profileKey);63- if (!raw) return null;64- const parsed = JSON.parse(raw) as Partial<AccountProfile>;65- if (typeof parsed?.rpId !== "string") return null;66- return {67- rpId: parsed.rpId,68- portable: parsed.portable === true,69- label: typeof parsed.label === "string" ? parsed.label : referenceApp.name,70- };71- } catch {72- return null;73- }74-}75-76-function saveProfile(profile: AccountProfile): void {77- try {78- localStorage?.setItem(profileKey, JSON.stringify(profile));79- } catch {80- // A private-mode storage failure must not fail the sign-in itself.81- }34+/** Reads the current session. Synchronous — it never prompts or touches the network. */35+export function readSession(): Session {36+ return { syncAvailable, backedUp: syncElected(), syncing: syncing() };8237 }8338 84-function clearProfile(): void {85- try {86- localStorage?.removeItem(profileKey);87- } catch {88- // Nothing to clean up if storage is unavailable.89- }39+// The account secret the runtime is already using. `getOrCreateSecret` is a40+// read-through: the runtime created it on first boot, so this returns that same41+// secret rather than minting a new one.42+async function currentSecret(): Promise<string> {43+ return await BrowserAuthSecretStore.getOrCreateSecret({ appId });9044 }9145 92-/** Reads the current session without prompting for any credential. */93-export async function readSession(): Promise<Session> {94- const identity = referenceApp.identity;95- const syncing = Boolean(serverUrl);96- if (identity !== "device-passkey") {97- return {98- identity,99- requiresPasskey: false,100- signedIn: true,101- syncing,102- capability: null,103- profile: null,104- };105- }106- const [capability, cached] = await Promise.all([107- getAuthCapability(),108- BrowserAuthSecretStore.loadSecret({ appId }),109- ]);110- return {111- identity,112- requiresPasskey: true,113- signedIn: Boolean(cached),114- syncing,115- capability,116- profile: loadProfile(),117- };118-}119-120-// Caches the derived secret, remembers the credential, and reopens the runtime121-// so every consumer reconnects against the now-available account.122-async function activate(123- credential: DeviceCredential,124- secret: string,125- label: string,126-): Promise<Session> {127- await BrowserAuthSecretStore.saveSecret(secret, { appId });128- saveProfile({ rpId: credential.rpId, portable: credential.portable, label });129- await recreateRuntime();130- globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));131- return await readSession();46+/**47+ * Reveals the current account's recovery phrase — the same 32-byte secret encoded48+ * as words. Show it for the user to write down; never persist it. Works whether49+ * or not sync is on, but only matters once the account syncs, since the phrase50+ * recovers what has been backed up.51+ */52+export async function revealRecoveryPhrase(): Promise<string> {53+ return toRecoveryPhrase(await currentSecret());13254 }13355 13456 /**135- * Registers a brand-new passkey and derives the account it represents. Use this136- * for a first-time account; the `label` names the key in the user's password137- * manager. Prefer a portable (roaming) passkey so the account reaches other138- * devices — {@link Session.profile} reports which kind was created.57+ * Elects to back up and sync this account: replication to the managed Jazz app58+ * turns on and the existing local data pushes up under the same identity. Pair59+ * it with {@link revealRecoveryPhrase} so the user has a way back in. No-op when60+ * no Jazz app is configured.13961 */140-export async function createPasskeyAccount(label: string = referenceApp.name): Promise<Session> {141- // create() reports backup-eligibility from its attestation; the follow-up142- // derive uses PRF (a get()), which is the portable way to obtain the secret.143- const enrolled = await enrollDeviceCredential({ label });144- const account = await deriveAccount();145- return await activate(146- { ...account.credential, portable: enrolled.portable },147- account.secret,148- label,149- );62+export async function enableSyncBackup(): Promise<Session> {63+ setSyncElected(true);64+ await recreateRuntime();65+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));66+ return readSession();15067 }15168 15269 /**153- * Signs in with an existing passkey in a single ceremony — the key reconstructs154- * the same account it always derives. Throws `prf-unavailable` if the client155- * cannot do PRF and `cancelled` if the user dismisses the prompt.70+ * Stops replicating this account to the server and returns to local-only. The71+ * local data and the account are untouched — this only detaches the network, so72+ * electing to sync again resumes against the same account.15673 */157-export async function signInWithPasskey(): Promise<Session> {158- const account = await deriveAccount();159- const label = loadProfile()?.label ?? referenceApp.name;160- return await activate(account.credential, account.secret, label);74+export async function stopSyncBackup(): Promise<Session> {75+ setSyncElected(false);76+ await recreateRuntime();77+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));78+ return readSession();16179 }16280 16381 /**164- * Forgets the cached account on this device and returns to the signed-out gate.165- * The passkey itself is untouched — signing in again re-derives the same166- * account. There is no server-side account, so nothing else is revoked.82+ * Recovers an account from its recovery phrase: the phrase reconstructs the83+ * exact account secret, which replaces the one on this device. Sync is elected84+ * so the recovered account's data syncs back down, and the runtime is recreated85+ * to open it. Throws {@link RecoveryError} on a malformed phrase — the account is86+ * never replaced with a fabricated secret.87+ *88+ * This overwrites whatever account this device currently holds, so a caller89+ * should confirm the intent before discarding a local-only account's data.16790 */168-export async function signOut(): Promise<Session> {169- await shutdownRuntime();170- await BrowserAuthSecretStore.clearSecret({ appId });171- clearProfile();91+export async function restoreFromRecoveryPhrase(phrase: string): Promise<Session> {92+ const secret = fromRecoveryPhrase(phrase);93+ await BrowserAuthSecretStore.saveSecret(secret, { appId });94+ setSyncElected(true);95+ await recreateRuntime();17296 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));173- return await readSession();97+ return readSession();17498 }17599 176-/** True when an error means "no account secret yet" — render the sign-in gate. */177-export function isSignedOut(error: unknown): boolean {178- return error instanceof AuthError && error.code === "credential-missing";100+/** True when an error is a recovery-phrase problem the user can fix and retry. */101+export function isRecoveryError(error: unknown): error is RecoveryError {102+ return error instanceof RecoveryError;179103 }
M apps/reference/src/app.ts +9 -10
@@ -6,16 +6,15 @@ export const referenceApp = {
66 databaseName: "lofi-prototype",77 schema,88 storage: "durable" as const,9- // "device-passkey" (default) makes a portable passkey the account (derived from10- // its PRF), so the same key reaches the same account on any device — sign-in is11- // the AccountGate island. "device-local" gives each device its own random12- // account with no sign-in. See docs/auth-identity.md.13- identity: "device-passkey" as "device-local" | "device-passkey",14- // Hostnames you have committed to keeping stable, where a device passkey may be15- // enrolled (exact, or a `*.` suffix pattern). A passkey binds to its origin. Left16- // empty, the origin you are actually served from is trusted so the app works17- // wherever you deploy it — but a passkey breaks if that host later changes, so18- // pin your permanent hostname(s) here before shipping.9+ // Identity is local-first: first boot opens a private, on-device account with10+ // no sign-in. When a managed Jazz app is configured (JAZZ_APP_ID / JAZZ_SERVER_URL —11+ // see `deno task jazz:provision`), the AccountGate island lets the user elect to12+ // back up and sync it, and recover it from a phrase. See docs/auth-identity.md.13+ //14+ // Hostnames you have committed to keeping stable, for the optional device-credential15+ // primitive in `src/_lofi/auth.ts` (WebAuthn/PRF at-rest encryption). A credential16+ // binds to its origin; left empty, the served origin is trusted. Pin your permanent17+ // hostname(s) here before relying on a credential across a host change.1918 credentialOrigins: [] as readonly string[],2019 sync: { adapter: "jazz" as const },2120 // Source/home link shown in the starter footer. Point it at your own repo.
M apps/reference/src/islands/AccountGate.tsx +131 -148
@@ -1,133 +1,122 @@
11 import { useCallback, useEffect, useState } from "preact/hooks";2-import { referenceApp } from "../app.ts";3-import { AuthError } from "../_lofi/auth.ts";42 import {5- createPasskeyAccount,3+ enableSyncBackup,4+ isRecoveryError,65 readSession,6+ restoreFromRecoveryPhrase,7+ revealRecoveryPhrase,78 type Session,8- signInWithPasskey,9- signOut,9+ stopSyncBackup,1010 } from "../_lofi/session.ts";1111 1212 /**13- * Author-owned auth example. With `identity: "device-passkey"` (the default),14- * the passkey *is* the account: signing in derives the Jazz account secret from15- * the key, so the same passkey opens the same data on every device it reaches.13+ * Author-owned account example. lofi is local-first: the app already opened on a14+ * device-local account with no sign-in. This island offers the *opt-in* upgrade —15+ * back up and sync the account, and recover it elsewhere — using only the16+ * primitives in `src/_lofi/session.ts`. Delete it if your app is local-only, or17+ * restyle it freely; nothing under `src/_lofi/` needs to change.1618 *17- * This whole island is one pattern — gate the app on a user-gesture sign-in and18- * show which key you are signed in with. It uses only the primitives in19- * `src/_lofi/session.ts`; nothing under `src/_lofi/` needs to change. Delete it20- * (and set `identity: "device-local"`) if you do not want passkey accounts.19+ * It renders only when a managed Jazz app is configured (`JAZZ_APP_ID` /20+ * `JAZZ_SERVER_URL`, e.g. via `deno task jazz:provision`), because a recovery21+ * phrase only matters once there is somewhere to sync to.2122 */2223 23-// Turns an AuthError code into a sentence a person can act on.24+// Turns any thrown value into a sentence a person can act on.2425 function describe(error: unknown): string {25- if (error instanceof AuthError) {26- switch (error.code) {27- case "cancelled":28- return "Passkey prompt dismissed. Try again when you are ready.";29- case "prf-unavailable":30- return "This browser or key cannot derive an account (no WebAuthn PRF). Try a platform passkey in a current browser.";31- case "origin-rejected":32- return error.message;33- case "unsupported":34- return "This browser does not support passkeys.";35- case "credential-missing":36- return "No passkey was returned. Try again, or create an account.";37- default:38- return error.message;39- }40- }26+ if (isRecoveryError(error)) return error.message;4127 return error instanceof Error ? error.message : String(error);4228 }4329 4430 export default function AccountGate() {4531 const [session, setSession] = useState<Session | null>(null);46- const [busy, setBusy] = useState<"create" | "signin" | "signout" | null>(null);32+ const [busy, setBusy] = useState<"enable" | "stop" | "reveal" | "restore" | null>(null);4733 const [error, setError] = useState<string | null>(null);48- // The nickname the user gives their passkey — it becomes the credential's name49- // in their password manager, so they can tell which account a key unlocks.50- const [label, setLabel] = useState("");34+ const [phrase, setPhrase] = useState<string | null>(null);35+ const [phraseInput, setPhraseInput] = useState("");36+ const [restoring, setRestoring] = useState(false);5137 5238 useEffect(() => {53- let active = true;54- void readSession().then((next) => {55- if (active) setSession(next);56- });57- return () => {58- active = false;59- };39+ setSession(readSession());6040 }, []);6141 6242 const run = useCallback(63- (kind: "create" | "signin" | "signout", action: () => Promise<Session>) => {43+ (kind: "enable" | "stop" | "reveal" | "restore", action: () => Promise<unknown>) => {6444 setBusy(kind);6545 setError(null);66- void action().then((next) => {67- setSession(next);68- }, (cause) => {69- setError(describe(cause));70- }).finally(() => setBusy(null));46+ void action().then(47+ (result) => {48+ if (result && typeof result === "object" && "syncAvailable" in result) {49+ setSession(result as Session);50+ }51+ },52+ (cause) => setError(describe(cause)),53+ ).finally(() => setBusy(null));7154 },7255 [],7356 );7457 75- // Nothing to sign into under device-local identity; DeviceStatus reports it.76- if (!session || !session.requiresPasskey) return null;58+ // Nothing to back up to when no Jazz app is configured — stay out of the way.59+ if (!session || !session.syncAvailable) return null;7760 78- const { signedIn, syncing, capability, profile } = session;79- const prf = capability?.prf ?? "unknown";80- const origin = capability?.origin;81- const webAuthn = capability?.webAuthn ?? false;82- const prfBlocked = prf === "unavailable";83- const disabled = busy !== null || !webAuthn;61+ const disabled = busy !== null;8462 85- if (signedIn) {63+ const phraseBlock = phrase && (64+ <div class="account-phrase">65+ <p class="account-note">66+ Write these {phrase.split(" ").length}{" "}67+ words down and keep them somewhere safe. Anyone with them can open this account; lose every68+ copy and the account cannot be recovered.69+ </p>70+ <ol class="account-words" aria-label="Recovery phrase">71+ {phrase.split(" ").map((word, index) => <li key={index}>{word}</li>)}72+ </ol>73+ <button74+ type="button"75+ class="account-secondary"76+ onClick={() => void navigator.clipboard?.writeText(phrase).catch(() => {})}77+ >78+ Copy phrase79+ </button>80+ </div>81+ );82+83+ if (session.backedUp) {8684 return (8785 <section class="account account-in" aria-labelledby="account-title">8886 <header>8987 <p class="eyebrow">Account</p>90- <h2 id="account-title">Signed in</h2>88+ <h2 id="account-title">Backed up &amp; syncing</h2>9189 </header>92- <dl>93- <div>94- <dt>Key</dt>95- <dd>{profile?.label ?? "passkey"}</dd>96- </div>97- <div>98- <dt>Bound to</dt>99- <dd>{profile?.rpId ?? origin?.rpId ?? "this origin"}</dd>100- </div>101- <div>102- <dt>Portability</dt>103- <dd>104- {profile105- ? (profile.portable ? "roams across your devices" : "this device only")106- : "unknown"}107- </dd>108- </div>109- <div>110- <dt>Sync</dt>111- <dd>{syncing ? "syncing to your account" : "local-only"}</dd>112- </div>113- </dl>114- <button115- type="button"116- class="account-secondary"117- disabled={busy !== null}118- onClick={() => run("signout", signOut)}119- >120- {busy === "signout" ? "Signing out…" : "Sign out"}121- </button>12290 <p>123- Enable passkey identity in your own app in three steps: (1) set{" "}124- <code>identity: "device-passkey"</code> in <code>src/app.ts</code>{" "}125- (the default); (2) list your permanent hostname(s) in <code>credentialOrigins</code>{" "}126- before shipping — leave it empty and the served origin is trusted; (3) render this{" "}127- <code>AccountGate</code> island and drive sign-in with <code>createPasskeyAccount</code> /128- {" "}129- <code>signInWithPasskey</code> from <code>src/_lofi/session.ts</code>.91+ This account replicates to your managed Jazz app and can be recovered with its phrase.92+ Reveal the phrase again any time to save it on another device.13093 </p>94+ <div class="account-actions">95+ <button96+ type="button"97+ disabled={disabled}98+ onClick={() => run("reveal", () => revealRecoveryPhrase().then(setPhrase))}99+ >100+ {busy === "reveal"101+ ? "Revealing…"102+ : phrase103+ ? "Show phrase again"104+ : "Show recovery phrase"}105+ </button>106+ <button107+ type="button"108+ class="account-secondary"109+ disabled={disabled}110+ onClick={() => {111+ setPhrase(null);112+ run("stop", stopSyncBackup);113+ }}114+ >115+ {busy === "stop" ? "Stopping…" : "Stop syncing"}116+ </button>117+ </div>118+ {phraseBlock}119+ {error && <p class="account-error" role="alert">{error}</p>}131120 </section>132121 );133122 }
@@ -136,79 +125,73 @@ export default function AccountGate() {
136125 <section class="account account-out" aria-labelledby="account-title">137126 <header>138127 <p class="eyebrow">Account</p>139- <h2 id="account-title">Sign in with a passkey</h2>128+ <h2 id="account-title">Back up &amp; sync</h2>140129 </header>141130 <p>142- Your passkey is your account. Create one, or sign in with an existing one — the same key143- reaches the same data on every device it can.131+ You are working on a private, on-device account — no sign-in, nothing sent anywhere. Back it132+ up to sync across your devices and recover it if this one is lost. Your existing data comes133+ along.144134 </p>145- <div class="account-field">146- <label for="passkey-label">Name this passkey</label>147- <input148- id="passkey-label"149- value={label}150- onInput={(event) => setLabel(event.currentTarget.value)}151- placeholder={referenceApp.name}152- autocomplete="off"153- disabled={busy !== null}154- />155- <p class="account-note">Shown in your password manager. Defaults to the app name.</p>156- </div>157135 <div class="account-actions">158136 <button159137 type="button"160138 disabled={disabled}161- onClick={() => run("create", () => createPasskeyAccount(label.trim() || undefined))}139+ onClick={() =>140+ run("enable", async () => {141+ const words = await revealRecoveryPhrase();142+ setPhrase(words);143+ return await enableSyncBackup();144+ })}162145 >163- {busy === "create" ? "Creating…" : "Create passkey account"}146+ {busy === "enable" ? "Backing up…" : "Back up & enable sync"}164147 </button>165148 <button166149 type="button"167150 class="account-secondary"168151 disabled={disabled}169- onClick={() => run("signin", signInWithPasskey)}152+ onClick={() => {153+ setError(null);154+ setRestoring((value) => !value);155+ }}170156 >171- {busy === "signin" ? "Signing in…" : "Sign in with existing passkey"}157+ {restoring ? "Cancel" : "Restore from recovery phrase"}172158 </button>173159 </div>174- {error && <p class="account-error" role="alert">{error}</p>}175- <dl class="account-caps">176- <div>177- <dt>Passkeys</dt>178- <dd>{webAuthn ? "supported" : "unsupported in this browser"}</dd>179- </div>180- <div>181- <dt>PRF (account derivation)</dt>182- <dd>183- {prf === "available"184- ? "available"185- : prf === "not-reported"186- ? "not reported — will be tried"187- : prf === "unavailable"188- ? "unavailable"189- : "unknown until you try"}190- </dd>160+ {phraseBlock}161+ {restoring && (162+ <div class="account-field">163+ <label for="recovery-input">Recovery phrase</label>164+ <textarea165+ id="recovery-input"166+ value={phraseInput}167+ onInput={(event) => setPhraseInput(event.currentTarget.value)}168+ placeholder="word one word two …"169+ rows={3}170+ disabled={disabled}171+ />172+ <p class="account-note">173+ Restoring replaces this device's local account with the recovered one — any data created174+ here that is not backed up will be discarded.175+ </p>176+ <button177+ type="button"178+ disabled={disabled || phraseInput.trim().length === 0}179+ onClick={() =>180+ run(181+ "restore",182+ () =>183+ restoreFromRecoveryPhrase(phraseInput).then((next) => {184+ setRestoring(false);185+ setPhraseInput("");186+ return next;187+ }),188+ )}189+ >190+ {busy === "restore" ? "Restoring…" : "Restore account"}191+ </button>191192 </div>192- {origin && (193- <div>194- <dt>Origin ({origin.rpId || "unknown"})</dt>195- <dd>{origin.status}{origin.status !== "stable" ? ` — ${origin.action}` : ""}</dd>196- </div>197- )}198- </dl>199- {prfBlocked && (200- <p class="account-note">201- PRF is unavailable here, so account derivation cannot run — sign-in will report it rather202- than invent a key.203- </p>204- )}205- {origin?.status === "unverified" && (206- <p class="account-note">207- This origin is trusted by default so the app works wherever it is served, but a passkey208- enrolled here breaks if the host changes. Pin a permanent host in{" "}209- <code>credentialOrigins</code> (in <code>src/app.ts</code>) before shipping.210- </p>211193 )}194+ {error && <p class="account-error" role="alert">{error}</p>}212195 </section>213196 );214197 }
M apps/reference/src/islands/TaskList.tsx +1 -13
M apps/reference/src/islands/use-tasks.ts +4 -13
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "preact/hooks";
22 import { schema as s } from "jazz-tools";33 import { referenceApp } from "../app.ts";44 import { getRuntime, runtimeRecreatedEvent } from "../_lofi/runtime.ts";5-import { isSignedOut } from "../_lofi/session.ts";65 import type { TableSnapshot } from "../_lofi/table-store.ts";76 87 /**
@@ -25,22 +24,19 @@ const initial: TableSnapshot<Task> = {
2524 2625 export function useTasks() {2726 const [snapshot, setSnapshot] = useState(initial);28- // With `identity: "device-passkey"`, the runtime refuses to open until the29- // user signs in. That is not an error — it is the signed-out gate, so the UI30- // prompts to sign in instead of reporting a failed write. Signing in dispatches31- // `runtimeRecreatedEvent`, which reconnects us below.32- const [signedOut, setSignedOut] = useState(false);3327 3428 useEffect(() => {3529 let cancelled = false;3630 let connectionGeneration = 0;3731 let unsubscribe: (() => void) | undefined;32+ // The local-first account opens immediately, so this only reconnects when the33+ // runtime is recreated — e.g. after electing to sync or restoring an account,34+ // both of which dispatch `runtimeRecreatedEvent`.3835 const connect = () => {3936 const generation = ++connectionGeneration;4037 unsubscribe?.();4138 unsubscribe = undefined;4239 setSnapshot(initial);43- setSignedOut(false);4440 void getRuntime().then((runtime) => {4541 if (cancelled || generation !== connectionGeneration) return;4642 const store = runtime.store(tasksTable);
@@ -49,11 +45,6 @@ export function useTasks() {
4945 update();5046 }, (error) => {5147 if (cancelled || generation !== connectionGeneration) return;52- if (isSignedOut(error)) {53- setSignedOut(true);54- setSnapshot(initial);55- return;56- }5748 setSnapshot({5849 ...initial,5950 status: "error",
@@ -82,5 +73,5 @@ export function useTasks() {
8273 await (await store()).update(id, { completed });8374 }, [store]);8475 85- return { ...snapshot, signedOut, tasks: snapshot.rows, create, setCompleted };76+ return { ...snapshot, tasks: snapshot.rows, create, setCompleted };8677 }
M deno.json +2 -0
M docs/auth-identity.md +77 -82
@@ -1,105 +1,100 @@
1-# The key is the account1+# Local-first accounts: open now, back up later22 3-lofi's default identity model: **a passkey _is_ the account.** The Jazz account secret is derived4-from the credential, so the account lives wherever the key lives — and moves between devices exactly5-as far as the key does.3+lofi's identity model is **local-first**. A new app opens immediately on a private, on-device4+account — no sign-in, no ceremony, nothing sent anywhere. From there the user can _elect_ to back up5+and sync that account, and recover it on another device. Electing to sync preserves everything6+created while local-only, because the account identity never changes.67 7-This is the default (`identity: "device-passkey"` in `src/app.ts`). Set `identity: "device-local"`8-for a random, device-bound account with no sign-in (each device gets its own account, and it never9-leaves that device).8+This is the pit of success: the app works the instant it loads, and the path to a durable,9+multi-device account is one opt-in click — not a wall the user hits before they can do anything.1010 11-## How it works11+## The three states1212 13-Jazz's account secret is a 32-byte value (base64url). The WebAuthn **PRF** extension returns a14-32-byte, credential-bound secret that is deterministic for a given `(credential, salt)`. So:13+1. **Local-only (first boot).** The runtime opens a random per-device account14+ (`BrowserAuthSecretStore.getOrCreateSecret`) and never waits. Writes are durable on the device;15+ nothing reaches the network. This is the whole experience for a purely local app.16+2. **Backed up & syncing (elected).** When a managed Jazz app is configured, the user elects to back17+ up and sync. Replication turns on, the existing local data pushes up under the _same_ identity,18+ and a recovery phrase becomes available.19+3. **Recovered (on another device).** Entering the recovery phrase reconstructs the exact account20+ secret, so the account — and the data that synced up — comes back down.21+22+The **AccountGate** island (`src/islands/AccountGate.tsx`) drives states 2 and 3. It renders only23+when a Jazz app is configured, because a recovery phrase only matters once there is somewhere to24+sync to.25+26+## How the account survives the upgrade27+28+A lofi account _is_ a 32-byte secret. The Jazz account id is a function of that secret, so the same29+secret always opens the same account — whether or not a sync server is attached:1530 1631 ```17-account secret = base64url( HKDF-SHA-256( PRF(passkey, fixed-salt) ) )32+local-only: createDb({ appId, secret }) → account X, on this device33+synced: createDb({ appId, secret, serverUrl }) → account X, now replicated1834 ```1935 20-The same credential always derives the same account. No account material is stored on any server; it21-is a pure function of the key.36+Electing to sync just recreates the runtime with `serverUrl` added. Because the secret (and thus the37+account id) is unchanged, every write made while local-only is already part of account X and syncs38+up. No migration, no copy, no new account.2239 23-### Boot flow (`device-passkey`, the default)40+## The recovery phrase is the portable backup2441 25-- **First boot on a device** → the runtime finds no cached secret and stays signed-out; the26- **AccountGate** island (`src/islands/AccountGate.tsx`) shows a sign-in card. A WebAuthn ceremony27- needs a user gesture, so this is a button, not an automatic prompt.28-- **Sign in / create account** (a click) → derive the secret from the passkey → hand it to Jazz →29- cache it locally (`BrowserAuthSecretStore.saveSecret`) → recreate the runtime so data opens.30-- **Return boots** → the cached secret is used; no ceremony. "Identity never waits" holds once you31- are signed in on a device.32-- **A new device** → sign in with the same portable key → the same PRF → the same secret → the same33- Jazz account → your data syncs down and decrypts. Jazz already E2E-encrypts synced data keyed on34- the account, so nothing extra is needed for confidentiality.35-- **Sign out** → forgets the cached secret on this device only (`clearSecret`); the passkey is36- untouched and signs back into the same account.42+The same 32-byte secret encodes as a 24-word recovery phrase (`RecoveryPhrase.fromSecret` /43+`.toSecret`, via `jazz-tools/passphrase`):44+45+```46+recovery phrase = words( account secret ) # the phrase *is* the account47+```3748 38-The framework side of this lives in `src/_lofi/session.ts` (`readSession`, `createPasskeyAccount`,39-`signInWithPasskey`, `signOut`); the gate is the author-owned example you can restyle or replace.49+- **Back up** → reveal the phrase once and write it down. It is the exact same account, offline and50+ portable.51+- **Recover** → type the phrase on any device → the same secret → the same account → its synced data52+ flows back down, decrypted (Jazz E2E-encrypts synced data keyed on the account).4053 41-## Portable vs device-bound — and telling the user which they chose54+Restoring replaces whatever account the device currently holds, so the UI confirms before discarding55+a local-only account's un-backed-up data.4256 43-A passkey binds to its origin (RP ID) and roams (or doesn't) based on the credential provider, not44-the OS. `enrollDeviceCredential` reports this via the WebAuthn **backup-eligible (BE)** flag:57+### Boot flow4558 46-- **Portable** (`portable: true`) — a password-manager or platform-synced passkey (1Password,47- iCloud, Google) or a hardware key you carry. **The account travels with it.** Favor these.48-- **Device-bound** (`portable: false`) — Touch ID / Windows Hello without sync. **The account only49- exists on this device.** Allowed, but the app should say so: _use a portable passkey to reach this50- account elsewhere._59+- **First boot** → local-only account opens immediately. No gate.60+- **Back up & sync** (a click) → reveal the recovery phrase to save → turn on sync → the runtime61+ recreates and the account replicates.62+- **Return boots** → the same cached secret opens the same account, synced or not.63+- **A new device** → restore from the recovery phrase → the account and its synced data appear.64+- **Stop syncing** → detaches the network and returns to local-only; the account and data are65+ untouched, and electing again resumes against the same account.5166 52-The nickname passed as `label` becomes the passkey's name in the user's password manager, so they53-can see which account a key unlocks.67+The framework side lives in `src/_lofi/session.ts` (`readSession`, `revealRecoveryPhrase`,68+`enableSyncBackup`, `stopSyncBackup`, `restoreFromRecoveryPhrase`); the gate is the author-owned69+example you can restyle or replace.5470 5571 ## Custody and recovery — stated plainly5672 57-- **No recovery service.** lofi never holds recoverable account material. Recovery is delegated to58- _where the key lives_: your password manager's own sync, or a second enrolled hardware key.59-- **Lose every copy of the key → lose the account.** That is the honest cost of no-central-custody,60- and it is the same custody the device-local identity already has.61-- **This is not the rejected passkey-_backup_ ceremony.** The Jazz alpha's server-assisted passkey62- account backup was rejected on security grounds. This model has no server-side account backup at63- all — the account is derived locally from the key — so it sidesteps that entirely.64-65-## Guardrails (honest by construction)66-67-- **Feature-detected.** `getAuthCapability()` reports `webAuthn` + `prf`; if PRF is unavailable,68- derivation throws `prf-unavailable` rather than fabricating a secret.69-- **Origin-gated.** Enrollment and derivation refuse insecure origins (non-HTTPS, bare IPs) and any70- host an explicit `credentialOrigins` allowlist excludes — a passkey silently breaks if its origin71- changes. With no allowlist, the served origin is trusted so the app works wherever you deploy it72- (see [Origin trust](#origin-trust)).73-74-## Origin trust75-76-A passkey binds to its origin hostname (its RP ID) and silently breaks if that host changes. lofi77-reads the current origin automatically; `credentialOrigins` is a separate promise that a host is78-**permanent**:79-80-```ts81-// src/app.ts82-credentialOrigins: [], // empty → trust the origin you are served from (works anywhere you deploy)83-credentialOrigins: ["app.example.com", "*.example.com"], // pin permanent host(s) before shipping84-```73+- **No recovery service.** lofi never holds recoverable account material. Recovery is the recovery74+ phrase, held by the user. Sync replicates data to the managed Jazz app under an account only the75+ key can open.76+- **Lose every copy of the phrase _and_ the device → lose the account.** That is the honest cost of77+ no-central-custody.78+- **This is not a server-assisted account-backup ceremony.** The account is a local-first secret the79+ user backs up themselves; nothing on the server can reconstruct it.8580 86-Left empty, the served origin is trusted so the app works on any deployment, but a passkey enrolled87-there dies if the host later changes (e.g. an ephemeral preview URL). Pin your permanent hostname(s)88-once you have committed to them. Loopback (`localhost`) is trusted for local development but89-reported as `local-only` because such credentials do not transfer between machines.81+## Provisioning sync9082 91-## Using the primitives directly83+A managed Jazz app supplies `JAZZ_APP_ID` / `JAZZ_SERVER_URL` (the public pair the runtime reads)84+plus server-only secrets. Generate one and write `.env` in one step:9285 93-The default `device-passkey` wiring is done for you by `src/_lofi/session.ts` and the AccountGate94-island. To drive it yourself:86+```sh87+deno task jazz:provision # existing project88+deno run -A jsr:@nzip/lofi/create --sync my-app # at scaffold time89+```9590 96-```ts97-import { deriveAccount, getAuthCapability } from "./_lofi/auth.ts";91+The generated app is unclaimed — claim it from the Jazz dashboard within the grace window shown, or92+it expires. `.env` holds the server-only secrets and is git-ignored.9893 99-const { prf, origin } = await getAuthCapability();100-if (prf !== "unavailable" && origin.status !== "blocked") {101- // One user-gesture ceremony: derive the Jazz account secret and learn which key unlocked it.102- const { secret, credential } = await deriveAccount();103- // credential.portable === true → the account travels with this key104-}105-```94+## Optional: protecting the secret at rest95+96+`src/_lofi/auth.ts` is a standalone device-credential primitive: enroll a WebAuthn passkey and97+derive a credential-bound key (PRF) to encrypt data — including the account secret — **at rest**. It98+is feature-detected and never faked. It is _not_ the account identity (deriving the account from a99+credential was removed, since a derived key is a different account and cannot carry local-only data100+forward). See [docs/auth.md](auth.md).
M package/commands/dev.ts +2 -2
A package/commands/provision.ts +67 -0
@@ -0,0 +1,67 @@
1+#!/usr/bin/env -S deno run -A2+3+/**4+ * The `deno task jazz:provision` command: generates a managed Jazz app and5+ * writes its configuration into `.env`, so the project can offer synced,6+ * backed-up accounts. Prints the new app id and how to claim it — never the7+ * secrets, which live only in the git-ignored `.env`.8+ *9+ * @module10+ */11+12+import {13+ JAZZ_CLAIM_WINDOW_DAYS,14+ JAZZ_DASHBOARD_URL,15+ ProvisionError,16+ provisionJazzApp,17+} from "../tooling/provision.ts";18+19+function parseArgs(args: string[]): { path: string; force: boolean } {20+ let path = ".env";21+ let force = false;22+ for (let index = 0; index < args.length; index++) {23+ const arg = args[index];24+ if (arg === "--force") force = true;25+ else if (arg === "--env") {26+ const value = args[++index];27+ if (!value) {28+ console.error("usage: deno task jazz:provision [--env <path>] [--force]");29+ Deno.exit(2);30+ }31+ path = value;32+ } else {33+ console.error(`error: unknown argument ${JSON.stringify(arg)}`);34+ console.error("usage: deno task jazz:provision [--env <path>] [--force]");35+ Deno.exit(2);36+ }37+ }38+ return { path, force };39+}40+41+const { path, force } = parseArgs(Deno.args);42+43+try {44+ const result = await provisionJazzApp({ path, force });45+ console.log(`Provisioned Jazz app ${result.app.appId}`);46+ console.log(` Sync server: ${result.serverUrl}`);47+ console.log(48+ ` ${49+ result.replaced ? "Replaced" : "Wrote"50+ } JAZZ_APP_ID / JAZZ_SERVER_URL / JAZZ_ADMIN_SECRET / BACKEND_SECRET in ${result.path}`,51+ );52+ console.log("");53+ console.log(54+ `Claim this app within ${JAZZ_CLAIM_WINDOW_DAYS} days at ${JAZZ_DASHBOARD_URL} (use the admin`,55+ );56+ console.log(57+ `secret saved in ${result.path}); unclaimed apps expire. Keep ${result.path} private`,58+ );59+ console.log("— it holds server-only secrets and is already git-ignored.");60+} catch (error) {61+ if (error instanceof ProvisionError) {62+ console.error(`error: ${error.message}`);63+ Deno.exit(1);64+ }65+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);66+ Deno.exit(1);67+}
M package/create.ts +42 -4
@@ -2,19 +2,41 @@
22 33 /**44 * The `deno run -A jsr:@nzip/lofi/create <name>` command: scaffolds a new5- * local-first PWA project from the validated starter template.5+ * local-first PWA project from the validated starter template. Pass `--sync` to6+ * also provision a managed Jazz app and write it into the project's `.env`, so7+ * the app can offer synced, backed-up accounts from the first boot.68 *79 * @module810 */911 12+import { join } from "node:path";1013 import { createProject } from "./create_core.ts";14+import {15+ JAZZ_CLAIM_WINDOW_DAYS,16+ JAZZ_DASHBOARD_URL,17+ ProvisionError,18+ provisionJazzApp,19+} from "./tooling/provision.ts";1120 1221 function usage(): never {13- console.error("usage: deno run -A jsr:@nzip/lofi/create <name>");22+ console.error("usage: deno run -A jsr:@nzip/lofi/create [--sync] <name>");1423 Deno.exit(2);1524 }1625 17-if (Deno.args.length !== 1 || Deno.args[0].startsWith("-")) usage();26+function parseArgs(args: string[]): { name: string; sync: boolean } {27+ let name: string | undefined;28+ let sync = false;29+ for (const arg of args) {30+ if (arg === "--sync") sync = true;31+ else if (arg.startsWith("-")) usage();32+ else if (name === undefined) name = arg;33+ else usage();34+ }35+ if (name === undefined) usage();36+ return { name, sync };37+}38+39+const { name, sync } = parseArgs(Deno.args);1840 1941 try {2042 const developmentPrefix = Deno.env.get("LOFI_CREATE_DEVELOPMENT") === "1"
@@ -28,14 +50,30 @@ try {
2850 }2951 const result = await createProject({3052 cwd: Deno.cwd(),31- name: Deno.args[0],53+ name,3254 packagePrefix: developmentPrefix,3355 });3456 console.log(`Created ${result.displayPath}`);57+58+ if (sync) {59+ console.log("");60+ console.log("Provisioning a managed Jazz app for sync…");61+ const provisioned = await provisionJazzApp({ path: join(result.destination, ".env") });62+ console.log(` Jazz app ${provisioned.app.appId} → wrote .env`);63+ console.log(64+ ` Claim it within ${JAZZ_CLAIM_WINDOW_DAYS} days at ${JAZZ_DASHBOARD_URL} (admin secret is in .env); unclaimed apps expire.`,65+ );66+ }67+3568 console.log("");3669 console.log("Next:");3770 for (const command of result.nextCommands) console.log(` ${command}`);3871 } catch (error) {72+ if (error instanceof ProvisionError) {73+ console.error(`error: project created, but sync provisioning failed: ${error.message}`);74+ console.error("Run `deno task jazz:provision` from the project once resolved.");75+ Deno.exit(1);76+ }3977 console.error(`error: ${error instanceof Error ? error.message : String(error)}`);4078 Deno.exit(1);4179 }
M package/create_core.ts +14 -5
@@ -32,6 +32,7 @@ test-results/
3232 3333 const generatedEnvironment =3434 `# Optional public Jazz cloud configuration. Leave both blank for local-only mode.35+# Run \`deno task jazz:provision\` to generate a managed Jazz app and fill these in.3536 JAZZ_APP_ID=3637 JAZZ_SERVER_URL=3738
@@ -49,12 +50,19 @@ This local-first PWA was created with \`@nzip/lofi\`.
4950 deno task dev5051 \`\`\`5152 52-The default project runs local-only with durable browser storage. To enable managed sync, copy53-\`.env.example\` to \`.env\` and set both public Jazz values. Run \`deno task doctor\` before boot when54-you want configuration diagnostics without starting the application.53+The default project runs local-only with durable browser storage — it opens instantly on a private,54+on-device account with no sign-in. To enable managed sync and account backup, run55+\`deno task jazz:provision\` (it generates a managed Jazz app and writes \`.env\`), then rebuild. Run56+\`deno task doctor\` before boot when you want configuration diagnostics without starting the app.5557 56-Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Schema and sync tasks:57-\`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.58+## Accounts: back up & recover59+60+With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local account61+and recover it from a 24-word recovery phrase. The account they already have carries over — electing62+to sync never changes its identity. See the framework side in \`src/_lofi/session.ts\`.63+64+Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:65+\`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.5866 5967 ## Hosting6068
@@ -78,6 +86,7 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
7886 "@nzip/lofi/dev": { subpath: "dev", localPath: "commands/dev.ts" },7987 "@nzip/lofi/doctor": { subpath: "doctor", localPath: "commands/doctor.ts" },8088 "@nzip/lofi/preview": { subpath: "preview", localPath: "commands/preview.ts" },89+ "@nzip/lofi/provision": { subpath: "provision", localPath: "commands/provision.ts" },8190 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },8291 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },8392 };
M package/create_core_test.ts +2 -2
M package/starter_template.ts +2 -0
M package/testdata/starter.snapshot.json +18 -16
@@ -1,32 +1,34 @@
11 {2- ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",2+ ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",33 ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",44 "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",5- "deno.json": "afcf5ade84512c242a1466ac5a9ee24b306d4e045a83b1610942d5129a943672",5+ "deno.json": "399302f874085364bedc3f0cc23361a1395ae8a7c09df49da36217a9c4c63626",66 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",88 "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",9- "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10- "src/_lofi/auth_test.ts": "ceaacbb00aa61c4ceea44340dfe4cb68baecf1415b733a21ab45c0ea6c03a0bd",11- "src/_lofi/auth.ts": "a26e5e515a298ea300ffc88caff807f1d318da113dffd8075b7caf5cdf68743e",9+ "README.md": "e4434ba89b916d0b832a9044497e1538f35873dbc0b1c49001f1a3d0f54f4e78",10+ "src/_lofi/auth_test.ts": "771789f0bc48f27c95340e7d8dac452cf87a63e536736a00ac006433aa16e83b",11+ "src/_lofi/auth.ts": "19c0cfb08f0c00a05421bdcfcbd01ad099e52997793cbf0e89de0e25d385fc4f",1212 "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",13- "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",13+ "src/_lofi/config.ts": "374e8eba1c08cf80bec19cdb35c6ef242ae15cc859700013fec9c92d900c70fe",1414 "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",1515 "src/_lofi/device-capabilities.ts": "6911fbe8ce93cdd79531982fdafd505202141985baec874e4dd965b02e6115d3",16- "src/_lofi/DeviceStatus.tsx": "876482b5991e3c5398d92c1452323d330d4765963b38e7ceae394f481861be4e",16+ "src/_lofi/DeviceStatus.tsx": "0b5f4480ae544f3183080dbdcaf7d7b0a1030e5ac3433ace78639412b46c98d6",1717 "src/_lofi/diagnostics.ts": "abb2a5f8e4d6b8ab1a86d475db6c374aecf4580b534ba9d7d850e1dbb29bb7f8",1818 "src/_lofi/foreground-recovery_test.ts": "a5081aecaa70b71c63d721a0875f4d2b3898a49879eb8045c8e74baf0d867444",1919 "src/_lofi/foreground-recovery.ts": "a5ed7a65b9454770de62a4ba8cdac7b0d1a867ec3c703049d2c103791c4d0049",20- "src/_lofi/inspector_test.ts": "895896572059b37bb8d320953839106efa3a8caa55754b9fbbab781303525a58",21- "src/_lofi/inspector.ts": "c9f646b17954e07ecece69f9cbef2e3c4a70cd12f62a549220a3f34bee477cf2",20+ "src/_lofi/inspector_test.ts": "cc0d5514cf792cf1627f277794dcb6fd511e2d2c8f1e79bda47b24833ce53394",21+ "src/_lofi/inspector.ts": "1638b01913c97e01faf86d721128a8d6e1c8df0fbf446c04b25e0e53f944b424",2222 "src/_lofi/lifecycle.ts": "0652ce9d4564b830771522986e9d3d6a2afe0849661b43f2d8b4e47ff16c1a1d",23- "src/_lofi/probe.ts": "818cd9546c900ba482fc6ae8420a5a6e47b8f106ac56dc89c3c647a31aa1c704",23+ "src/_lofi/probe.ts": "20011497e8f6f14825fe76348e5b1e96b5e42bc466d8b9a5598cf8e65653ec16",2424 "src/_lofi/pwa_test.ts": "bc9c04ff61e2f5a0dfd4601fbab929aa3815f4b174fad600d68b1f74f070d346",2525 "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",26+ "src/_lofi/recovery_test.ts": "4fffd6ba780389e1411985377f7037e9007b78174fea5e036dc201575a0aa87c",27+ "src/_lofi/recovery.ts": "59b5e0a77dae3650611b7b9b807d1e75f749db934f8b5aae7fc34cda72ec56ea",2628 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2729 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",28- "src/_lofi/runtime.ts": "58975d41c648de6ecddeee11a1755a02aadc9490f5c609a58bce9f7fe540c6a6",29- "src/_lofi/session.ts": "4ae10c831c89e11e3be7625901b6ace5eeafa6f3403fe92c8c882ee12eb71195",30+ "src/_lofi/runtime.ts": "a33d7a802d4ae0a5d34efc11f958a25f55f84db23fa3732a468ed427c74eb3ac",31+ "src/_lofi/session.ts": "be22b8ed6196d90409ef4448094124f47781f402edf8f28cc11835a9c507d903",3032 "src/_lofi/Shell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",3133 "src/_lofi/table-store_test.ts": "1b8cfd06192b9c0daacc8a8c7811b691338f4ef459b209b3830fb87a0a039955",3234 "src/_lofi/table-store.ts": "7ad445cc34c11e0cab3afcf1cde097345f1a53f2583da6c7fd00c7cb3e082df8",
@@ -34,11 +36,11 @@
3436 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",3537 "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",3638 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",37- "src/app.ts": "c0ddd4f1994c19ca49de5a7c545b9306d9dbe8cfc7579bd9c71d6459965764f3",39+ "src/app.ts": "7e65c5308f645164357b02944d1e265f2be942686a634c552add14cfb8fd384f",3840 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",39- "src/islands/AccountGate.tsx": "e6de6be7148f12b5b113109a70efa12c6fe3020a1a3ce6b5f522fe96cb75898e",40- "src/islands/TaskList.tsx": "81d7fc95248baf4f51e1d53374d7b4700bf165049e846fdcc2694cbeb873652c",41- "src/islands/use-tasks.ts": "95573b22d5dd1e0e8303ae4536df9a4f0cb27f0c1243aa6c70a6366cc6b7530f",41+ "src/islands/AccountGate.tsx": "84573d9f5b66a435152fe5fdcdb698b382b2f54960e361ace00ad75d77d72b1a",42+ "src/islands/TaskList.tsx": "a3d054078819ea8b5609a014e94878bf1b21b2908c340237ed029e80e58f2349",43+ "src/islands/use-tasks.ts": "1df5748b14427755352b8438977ce8720abc42f1e2f69bf1e927ceaa52166246",4244 "src/pages/index.astro": "6bcbcbd1c1aad8d55855e5f52473b7a7711c439d4877ef54fe7f6f5d4f88ec3c",4345 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",4446 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",
M package/tooling/diagnostics.ts +4 -2
A package/tooling/provision.ts +209 -0
@@ -0,0 +1,209 @@
1+/**2+ * Bootstrap a managed Jazz app for a lofi project.3+ *4+ * Local-first projects run with no configuration at all. To turn on managed sync5+ * — so a user can elect to back up and roam their account — a project needs a6+ * Jazz app id and the managed sync server. Jazz's dashboard exposes a public7+ * endpoint that mints a fresh app and hands back its secrets; the app is8+ * unclaimed and expires unless you claim it from the dashboard within the9+ * grace window.10+ *11+ * This module is the small, honest primitive behind `deno task jazz:provision`12+ * and `create --sync`: it calls that endpoint, maps the response onto lofi's13+ * four environment names, and merges them into a `.env` file without disturbing14+ * anything else there. Secrets are written to the (git-ignored) file, never15+ * echoed to stdout.16+ *17+ * @module18+ */19+20+import { environmentNames, parseDotenv } from "./environment.ts";21+22+/** Jazz's public app-generation endpoint. Returns a fresh, unclaimed app. */23+export const JAZZ_GENERATE_ENDPOINT = "https://v2.dashboard.jazz.tools/api/apps/generate";24+/** The managed Jazz Cloud sync server a generated app syncs against. */25+export const JAZZ_MANAGED_SERVER_URL = "https://v2.sync.jazz.tools/";26+/** Where a generated app is claimed before it expires. */27+export const JAZZ_DASHBOARD_URL = "https://v2.dashboard.jazz.tools/";28+/** How long an unclaimed app survives — stated to the user, not enforced here. */29+export const JAZZ_CLAIM_WINDOW_DAYS = 14;30+31+/** The secrets a freshly generated Jazz app is provisioned with. */32+export type GeneratedJazzApp = {33+ /** Public app id (client-visible). Maps to `JAZZ_APP_ID`. */34+ appId: string;35+ /** Admin secret for schema/permission deploys. Maps to `JAZZ_ADMIN_SECRET`. */36+ adminSecret: string;37+ /** Backend secret for server-side use. Maps to `BACKEND_SECRET`. */38+ backendSecret: string;39+};40+41+/** A precise, non-leaking failure reason for provisioning. */42+export class ProvisionError extends Error {43+ override readonly name = "ProvisionError";44+ constructor(message: string) {45+ super(message);46+ }47+}48+49+function isNonEmptyString(value: unknown): value is string {50+ return typeof value === "string" && value.trim().length > 0;51+}52+53+/**54+ * Calls the Jazz app-generation endpoint and returns the new app's secrets.55+ * `fetchImpl` is injectable so the flow is testable without a network.56+ */57+export async function generateJazzApp(58+ fetchImpl: typeof fetch = fetch,59+): Promise<GeneratedJazzApp> {60+ let response: Response;61+ try {62+ response = await fetchImpl(JAZZ_GENERATE_ENDPOINT, {63+ method: "POST",64+ headers: { "content-type": "application/json" },65+ body: "{}",66+ });67+ } catch (cause) {68+ throw new ProvisionError(69+ `could not reach the Jazz dashboard to generate an app (${70+ cause instanceof Error ? cause.message : String(cause)71+ }); check your connection and retry`,72+ );73+ }74+ if (!response.ok) {75+ throw new ProvisionError(76+ `the Jazz dashboard rejected the app-generation request (HTTP ${response.status}); retry later`,77+ );78+ }79+ let payload: unknown;80+ try {81+ payload = await response.json();82+ } catch {83+ throw new ProvisionError("the Jazz dashboard returned a response that was not valid JSON");84+ }85+ const record = (payload ?? {}) as Record<string, unknown>;86+ if (87+ !isNonEmptyString(record.appId) ||88+ !isNonEmptyString(record.adminSecret) ||89+ !isNonEmptyString(record.backendSecret)90+ ) {91+ throw new ProvisionError(92+ "the Jazz dashboard response was missing an appId, adminSecret, or backendSecret",93+ );94+ }95+ return {96+ appId: record.appId.trim(),97+ adminSecret: record.adminSecret.trim(),98+ backendSecret: record.backendSecret.trim(),99+ };100+}101+102+/** The four environment values a generated app populates. */103+export function environmentForApp(104+ app: GeneratedJazzApp,105+ serverUrl: string = JAZZ_MANAGED_SERVER_URL,106+): Record<string, string> {107+ return {108+ JAZZ_APP_ID: app.appId,109+ JAZZ_SERVER_URL: serverUrl,110+ JAZZ_ADMIN_SECRET: app.adminSecret,111+ BACKEND_SECRET: app.backendSecret,112+ };113+}114+115+/**116+ * Merges `values` into the text of a `.env` file, updating existing assignments117+ * in place and appending any that are absent. Every other line — comments,118+ * blank lines, unrelated keys — is preserved verbatim.119+ */120+export function mergeEnv(existing: string, values: Record<string, string>): string {121+ const remaining = new Map(Object.entries(values));122+ const lines = existing.length === 0 ? [] : existing.split("\n");123+ const updated = lines.map((line) => {124+ const match = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=)/.exec(line);125+ if (!match) return line;126+ const name = match[2];127+ if (!remaining.has(name)) return line;128+ const value = remaining.get(name)!;129+ remaining.delete(name);130+ return `${match[1]}${name}=${value}`;131+ });132+ if (remaining.size > 0) {133+ if (updated.length > 0 && updated[updated.length - 1].trim() !== "") updated.push("");134+ for (const [name, value] of remaining) updated.push(`${name}=${value}`);135+ }136+ return `${updated.join("\n").replace(/\n+$/, "")}\n`;137+}138+139+/** Whether a parsed `.env` already points at a configured (non-empty) app. */140+function alreadyConfigured(existing: string): boolean {141+ const parsed = parseDotenv(existing);142+ return isNonEmptyString(parsed.JAZZ_APP_ID);143+}144+145+/** The outcome of a provisioning run, for the caller to report. */146+export type ProvisionResult = {147+ app: GeneratedJazzApp;148+ serverUrl: string;149+ /** The absolute or relative path the environment was written to. */150+ path: string;151+ /** Whether an existing configured app was replaced. */152+ replaced: boolean;153+};154+155+export type ProvisionOptions = {156+ /** The `.env` path to write. */157+ path: string;158+ /** Overwrite an already-configured `JAZZ_APP_ID`. */159+ force?: boolean;160+ /** Injectable network + filesystem for testing. */161+ fetchImpl?: typeof fetch;162+ readFile?: (path: string) => Promise<string>;163+ writeFile?: (path: string, contents: string) => Promise<void>;164+};165+166+const defaultReadFile = async (path: string): Promise<string> => {167+ try {168+ return await Deno.readTextFile(path);169+ } catch (error) {170+ if (error instanceof Deno.errors.NotFound) return "";171+ throw error;172+ }173+};174+175+const defaultWriteFile = (path: string, contents: string): Promise<void> =>176+ Deno.writeTextFile(path, contents);177+178+/**179+ * Generates a Jazz app and writes its configuration into `options.path`,180+ * refusing to clobber an already-configured app unless `force` is set. Returns181+ * what was provisioned; never returns or logs the secrets.182+ */183+export async function provisionJazzApp(options: ProvisionOptions): Promise<ProvisionResult> {184+ const readFile = options.readFile ?? defaultReadFile;185+ const writeFile = options.writeFile ?? defaultWriteFile;186+ const existing = await readFile(options.path);187+ const replaced = alreadyConfigured(existing);188+ if (replaced && !options.force) {189+ throw new ProvisionError(190+ `${options.path} already configures JAZZ_APP_ID; rerun with --force to replace it, or edit the file by hand`,191+ );192+ }193+ const app = await generateJazzApp(options.fetchImpl);194+ const serverUrl = JAZZ_MANAGED_SERVER_URL;195+ const merged = mergeEnv(existing, environmentForApp(app, serverUrl));196+ await writeFile(options.path, merged);197+ return { app, serverUrl, path: options.path, replaced };198+}199+200+// A defensive guard so a future edit to the env-name list cannot silently drop201+// one of the four names this module writes.202+const _names: readonly string[] = environmentNames;203+if (204+ !["JAZZ_APP_ID", "JAZZ_SERVER_URL", "JAZZ_ADMIN_SECRET", "BACKEND_SECRET"].every((name) =>205+ _names.includes(name)206+ )207+) {208+ throw new Error("provision: environment name list drifted from the four provisioned names");209+}
A package/tooling/provision_test.ts +138 -0
@@ -0,0 +1,138 @@
1+import {2+ environmentForApp,3+ generateJazzApp,4+ JAZZ_MANAGED_SERVER_URL,5+ mergeEnv,6+ ProvisionError,7+ provisionJazzApp,8+} from "./provision.ts";9+10+function assert(condition: unknown, message: string): asserts condition {11+ if (!condition) throw new Error(message);12+}13+14+function jsonResponse(body: unknown, status = 200): Response {15+ return new Response(JSON.stringify(body), {16+ status,17+ headers: { "content-type": "application/json" },18+ });19+}20+21+const sampleApp = {22+ appId: "00000000-0000-4000-8000-000000000000",23+ adminSecret: "admin-secret-value",24+ backendSecret: "backend-secret-value",25+};26+27+Deno.test("generateJazzApp returns the three secrets from a well-formed response", async () => {28+ const app = await generateJazzApp(() => Promise.resolve(jsonResponse(sampleApp)));29+ assert(app.appId === sampleApp.appId, "appId should round-trip");30+ assert(app.adminSecret === sampleApp.adminSecret, "adminSecret should round-trip");31+ assert(app.backendSecret === sampleApp.backendSecret, "backendSecret should round-trip");32+});33+34+Deno.test("generateJazzApp rejects a response missing a secret", async () => {35+ let threw = false;36+ try {37+ await generateJazzApp(() => Promise.resolve(jsonResponse({ appId: "only-id" })));38+ } catch (error) {39+ threw = error instanceof ProvisionError;40+ }41+ assert(threw, "a missing adminSecret/backendSecret should raise ProvisionError");42+});43+44+Deno.test("generateJazzApp surfaces a non-2xx status as a ProvisionError", async () => {45+ let threw = false;46+ try {47+ await generateJazzApp(() => Promise.resolve(jsonResponse({}, 503)));48+ } catch (error) {49+ threw = error instanceof ProvisionError;50+ }51+ assert(threw, "an HTTP error should raise ProvisionError");52+});53+54+Deno.test("environmentForApp maps onto the four lofi names", () => {55+ const env = environmentForApp(sampleApp);56+ assert(env.JAZZ_APP_ID === sampleApp.appId, "JAZZ_APP_ID");57+ assert(env.JAZZ_SERVER_URL === JAZZ_MANAGED_SERVER_URL, "JAZZ_SERVER_URL defaults to managed");58+ assert(env.JAZZ_ADMIN_SECRET === sampleApp.adminSecret, "JAZZ_ADMIN_SECRET");59+ assert(env.BACKEND_SECRET === sampleApp.backendSecret, "BACKEND_SECRET");60+});61+62+Deno.test("mergeEnv updates existing keys in place and preserves the rest", () => {63+ const existing = [64+ "# lofi config",65+ "JAZZ_APP_ID=",66+ "JAZZ_SERVER_URL=",67+ "",68+ "# unrelated",69+ "OTHER_KEY=keep-me",70+ "",71+ ].join("\n");72+ const merged = mergeEnv(existing, environmentForApp(sampleApp));73+ assert(merged.includes(`JAZZ_APP_ID=${sampleApp.appId}`), "app id is filled in place");74+ assert(merged.includes("OTHER_KEY=keep-me"), "unrelated key is preserved");75+ assert(merged.includes("# unrelated"), "comments are preserved");76+ assert(merged.includes(`JAZZ_ADMIN_SECRET=${sampleApp.adminSecret}`), "missing key is appended");77+ assert((merged.match(/JAZZ_APP_ID=/g) ?? []).length === 1, "no duplicate JAZZ_APP_ID");78+});79+80+Deno.test("mergeEnv appends all four keys to an empty file", () => {81+ const merged = mergeEnv("", environmentForApp(sampleApp));82+ for (const name of ["JAZZ_APP_ID", "JAZZ_SERVER_URL", "JAZZ_ADMIN_SECRET", "BACKEND_SECRET"]) {83+ assert(merged.includes(`${name}=`), `${name} should be written`);84+ }85+});86+87+Deno.test("provisionJazzApp writes an env file and reports the app", async () => {88+ let written = "";89+ const result = await provisionJazzApp({90+ path: ".env",91+ fetchImpl: () => Promise.resolve(jsonResponse(sampleApp)),92+ readFile: () => Promise.resolve(""),93+ writeFile: (_path, contents) => {94+ written = contents;95+ return Promise.resolve();96+ },97+ });98+ assert(result.app.appId === sampleApp.appId, "returns the generated app");99+ assert(result.replaced === false, "a fresh file is not a replacement");100+ assert(written.includes(`JAZZ_APP_ID=${sampleApp.appId}`), "env file received the app id");101+});102+103+Deno.test("provisionJazzApp refuses to clobber a configured app without force", async () => {104+ let fetched = false;105+ let threw = false;106+ try {107+ await provisionJazzApp({108+ path: ".env",109+ fetchImpl: () => {110+ fetched = true;111+ return Promise.resolve(jsonResponse(sampleApp));112+ },113+ readFile: () => Promise.resolve("JAZZ_APP_ID=already-set\n"),114+ writeFile: () => Promise.resolve(),115+ });116+ } catch (error) {117+ threw = error instanceof ProvisionError;118+ }119+ assert(threw, "an existing JAZZ_APP_ID should block without --force");120+ assert(!fetched, "it should refuse before generating a throwaway app");121+});122+123+Deno.test("provisionJazzApp replaces a configured app when forced", async () => {124+ let written = "";125+ const result = await provisionJazzApp({126+ path: ".env",127+ force: true,128+ fetchImpl: () => Promise.resolve(jsonResponse(sampleApp)),129+ readFile: () => Promise.resolve("JAZZ_APP_ID=already-set\nOTHER=keep\n"),130+ writeFile: (_path, contents) => {131+ written = contents;132+ return Promise.resolve();133+ },134+ });135+ assert(result.replaced === true, "an existing app is reported as replaced");136+ assert(written.includes(`JAZZ_APP_ID=${sampleApp.appId}`), "the app id is overwritten");137+ assert(written.includes("OTHER=keep"), "unrelated keys survive a forced replace");138+});