clone

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

feat(auth): default to device-passkey with a click-to-sign-in gate

Dami committed 3cee1f3
Make "the key is the account" the default identity and give it an honest,
gesture-driven sign-in instead of an auto-run boot ceremony (WebAuthn needs a
user activation).

- app.ts: identity defaults to device-passkey; credentialOrigins left empty now
  trusts the served origin (auto-read location.hostname) so the app works on any
  deploy — pin permanent hosts to survive a host change.
- auth.ts: currentOrigin trusts the current host by default; enrollment accepts
  stable + local-only (localhost dev); add deriveAccount() returning {secret,
  credential} from a single PRF ceremony.
- runtime.ts: device-passkey no longer auto-derives at boot — it waits for a
  cached secret and reports signed-out otherwise.
- session.ts (new): readSession / createPasskeyAccount / signInWithPasskey /
  signOut, profile persistence, runtimeRecreatedEvent dispatch.
- AccountGate.tsx (new): author-owned sign-in / create / status UI example.
- use-tasks/TaskList: render a signed-out prompt instead of an error.
- DeviceStatus: regrouped into shared-worker, data-sync, auth, and pwa
  categories that name the module to hook into; pulls live PWA + session state.
- index.astro: mount AccountGate, drop identity-note, footer links to the repo
  (via app.ts repositoryUrl, keeping literal URLs out of author UI).
- docs/auth-identity.md, dev banner, starter template + snapshot updated.
commit
3cee1f3a847284b6bf7bc903df21cd8e62807f70
parent
f130b757fe793ed4bdf80880a8eb191077b0144c
author
Dami <dami.etoile@gmail.com>
15 files changed, +819 -130
Mapps/reference/src/_lofi/DeviceStatus.tsx185++++++++++++++++++++--------
Mapps/reference/src/_lofi/auth.ts73+++++++++--
Mapps/reference/src/_lofi/auth_test.ts45+++++++
Mapps/reference/src/_lofi/runtime.ts16+-
Aapps/reference/src/_lofi/session.ts179+++++++++++++++++++++++++++
Mapps/reference/src/app.ts17++-
Aapps/reference/src/islands/AccountGate.tsx198++++++++++++++++++++++++++++++
Mapps/reference/src/islands/TaskList.tsx16++-
Mapps/reference/src/islands/use-tasks.ts14++-
Mapps/reference/src/pages/index.astro13+-
Mapps/reference/src/styles/global.css97++++++++++++++-
Mdocs/auth-identity.md70+++++++----
Mpackage/commands/dev.ts4+-
Mpackage/starter_template.ts2+
Mpackage/testdata/starter.snapshot.json20++-
M apps/reference/src/_lofi/DeviceStatus.tsx +130 -55
@@ -1,73 +1,148 @@
1+import { useEffect, useState } from "preact/hooks";12 import { referenceApp } from "../app.ts";23 import { serverUrl } from "./config.ts";34 import { useDeviceCapabilities } from "./use-device-capabilities.ts";45 import { settleUiMutation } from "./ui-mutation.ts";6+import { getPwaState, type PwaState, subscribePwaState } from "./pwa.ts";7+import { runtimeRecreatedEvent } from "./runtime.ts";8+import { readSession, type Session } from "./session.ts";9+10+/**11+ * The Device gate: every subsystem's live status, grouped by the system it12+ * belongs to. It doubles as a map of where to hook in — each category names the13+ * module that owns it, so a reading of the deployment is also a reading of the14+ * framework's seams.15+ */16+17+// One label/value row inside a category's definition list.18+function Row({ label, value }: { label: string; value: string }) {19+ return (20+ <div>21+ <dt>{label}</dt>22+ <dd>{value}</dd>23+ </div>24+ );25+}26+27+const available = (present: boolean) => (present ? "available" : "missing");528 629 export default function DeviceStatus() {730 const { report, requestPersistence } = useDeviceCapabilities();8- if (!report) return <p class="device-status">Checking durable-storage capabilities…</p>;31+ const [pwa, setPwa] = useState<PwaState>(getPwaState());32+ const [session, setSession] = useState<Session | null>(null);933 10- const passkeyIdentity = referenceApp.identity === "device-passkey";34+ useEffect(() => subscribePwaState(setPwa), []);35+ useEffect(() => {36+ let active = true;37+ const refresh = () => {38+ void readSession().then((next) => {39+ if (active) setSession(next);40+ });41+ };42+ refresh();43+ // Re-read after a sign-in / sign-out recreates the runtime.44+ globalThis.addEventListener(runtimeRecreatedEvent, refresh);45+ return () => {46+ active = false;47+ globalThis.removeEventListener(runtimeRecreatedEvent, refresh);48+ };49+ }, []);50+51+ if (!report) return <p class="device-status">Checking device capabilities…</p>;52+53+ const passkey = referenceApp.identity === "device-passkey";1154 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";1261 1362 return (1463 <section class="device-status" aria-labelledby="device-status-title">1564 <header>1665 <p class="eyebrow">Device gate</p>17- <h2 id="device-status-title">18- {report.durableDriverSupported ? "Durable driver supported" : "Durable driver blocked"}19- </h2>66+ <h2 id="device-status-title">Deployment status &amp; integration points</h2>2067 </header>21- <dl>22- <div>23- <dt>Context</dt>24- <dd>{report.secureContext ? "secure" : "not secure"}</dd>25- </div>26- <div>27- <dt>OPFS</dt>28- <dd>{report.opfs ? "available" : "missing"}</dd>29- </div>30- <div>31- <dt>SharedWorker</dt>32- <dd>{report.sharedWorker ? "available" : "missing"}</dd>33- </div>34- <div>35- <dt>Web Locks</dt>36- <dd>{report.webLocks ? "available" : "missing"}</dd>37- </div>38- <div>39- <dt>Storage persistence</dt>40- <dd>{report.persistentPermission}</dd>41- </div>42- <div>43- <dt>Display mode</dt>44- <dd>{report.displayMode}</dd>45- </div>46- <div>47- <dt>Identity</dt>48- <dd>{passkeyIdentity ? "passkey account" : "device-local key"}</dd>49- </div>50- <div>51- <dt>Sync</dt>52- <dd>{synced ? "syncing to your account" : "local-only"}</dd>53- </div>54- <div>55- <dt>Account portability</dt>56- <dd>{passkeyIdentity ? "travels with your passkey" : "stays on this device"}</dd>57- </div>58- </dl>59- <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>60- Request storage persistence61- </button>62- <p>63- OPFS capability and eviction protection are separate. A declined persistence request does64- not mean the Jazz driver is using memory.65- </p>66- <p>67- {passkeyIdentity68- ? "The passkey is the account: it lives wherever the key lives and reaches every device that key can. There is no recovery service — lose every copy of the key and the account is gone."69- : 'Clearing site data destroys the device-local identity. This reference claims no recovery; set identity: "device-passkey" in src/app.ts for a portable, cross-device account.'}70- </p>68+69+ <div class="device-category">70+ <h3>Shared worker &amp; durable storage</h3>71+ <p class="device-hook">72+ Hook in: <code>src/_lofi/device-capabilities.ts</code>, <code>runtime.ts</code>73+ </p>74+ <dl>75+ <Row76+ label="Durable driver"77+ value={report.durableDriverSupported ? "supported" : "blocked"}78+ />79+ <Row label="Secure context" value={report.secureContext ? "secure" : "not secure"} />80+ <Row label="OPFS" value={available(report.opfs)} />81+ <Row label="SharedWorker" value={available(report.sharedWorker)} />82+ <Row label="Web Locks" value={available(report.webLocks)} />83+ <Row label="MessageChannel" value={available(report.messageChannel)} />84+ <Row label="Storage persistence" value={report.persistentPermission} />85+ </dl>86+ <button type="button" onClick={() => void settleUiMutation(requestPersistence())}>87+ Request storage persistence88+ </button>89+ <p>90+ OPFS capability and eviction protection are separate. A declined persistence request does91+ not mean the Jazz driver is using memory.92+ </p>93+ </div>94+95+ <div class="device-category">96+ <h3>Data sync</h3>97+ <p class="device-hook">98+ Hook in: <code>src/_lofi/config.ts</code>, <code>runtime.ts</code>99+ </p>100+ <dl>101+ <Row label="Sync" value={synced ? "syncing to your account" : "local-only"} />102+ <Row label="Managed server" value={synced ? "configured" : "not configured"} />103+ </dl>104+ {!synced && (105+ <p>106+ Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in{" "}107+ <code>.env</code>, then rebuild, to replicate writes to your account.108+ </p>109+ )}110+ </div>111+112+ <div class="device-category">113+ <h3>Auth</h3>114+ <p class="device-hook">115+ Hook in: <code>src/_lofi/auth.ts</code>, <code>session.ts</code>116+ </p>117+ <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+ )}125+ <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} />}131+ </dl>132+ </div>133+134+ <div class="device-category">135+ <h3>PWA</h3>136+ <p class="device-hook">137+ Hook in: <code>src/_lofi/pwa.ts</code>138+ </p>139+ <dl>140+ <Row label="Display mode" value={report.displayMode} />141+ <Row label="Install" value={pwa.install} />142+ <Row label="Service worker" value={pwa.worker} />143+ </dl>144+ {pwa.failure && <p class="account-error">{pwa.failure.code}: {pwa.failure.message}</p>}145+ </div>71146 </section>72147 );73148 }
M apps/reference/src/_lofi/auth.ts +61 -12
@@ -218,13 +218,26 @@ function currentOrigin(trustedOrigins?: readonly string[]): CredentialOriginRepo
218218 if (typeof location === "undefined") {219219 return { status: "blocked", rpId: "", action: "open the application in a browser" };220220 }221- return classifyCredentialOrigin(new URL(location.href), trustedOrigins);221+ const url = new URL(location.href);222+ const declared = trustedOrigins ?? referenceApp.credentialOrigins ?? [];223+ // Default trust is the origin you are actually served from: the passkey binds224+ // to this hostname (its RP ID) regardless, so trusting it lets the app work225+ // wherever it is deployed. Pin explicit hosts in `app.ts` `credentialOrigins`226+ // to keep enrollment working across a host change — a passkey silently breaks227+ // if its origin moves.228+ const trusted = declared.length > 0 ? declared : [url.hostname];229+ return classifyCredentialOrigin(url, trusted);222230 }223231 224232 function requireStableRpId(dependencies: AuthDependencies): string {225233 if (dependencies.rpId) return dependencies.rpId;226234 const origin = currentOrigin(dependencies.trustedOrigins);227- if (origin.status !== "stable") throw new AuthError("origin-rejected", origin.action);235+ // Enroll where you are actually served: a committed HTTPS host (`stable`) or236+ // loopback during local development (`local-only`). Refuse insecure origins237+ // and hosts that an explicit `credentialOrigins` allowlist excludes.238+ if (origin.status !== "stable" && origin.status !== "local-only") {239+ throw new AuthError("origin-rejected", origin.action);240+ }228241 return origin.rpId;229242 }230243
@@ -312,16 +325,13 @@ export async function authenticateDeviceCredential(
312325 313326 // --- PRF-derived at-rest key --------------------------------------------------314327 315-/**316- * Derives a credential-bound secret from the passkey via the WebAuthn PRF317- * extension. The same `salt` on the same authenticator yields the same 32-byte318- * secret; it never leaves the device. Throws `prf-unavailable` if the client or319- * authenticator does not return a PRF result — the secret is never faked.320- */321-export async function derivePrfSecret(328+// Runs a single user-verifying `get()` with the PRF extension and returns both329+// the PRF secret and the asserting credential's identity, so a caller can derive330+// the account and report which key unlocked it from one ceremony (one prompt).331+async function prfAssertion(322332 salt: BufferSource,323- dependencies: AuthDependencies = {},324-): Promise<Uint8Array> {333+ dependencies: AuthDependencies,334+): Promise<{ prfSecret: Uint8Array; credential: DeviceCredential }> {325335 const rpId = requireStableRpId(dependencies);326336 const credentials = dependencies.credentials ?? browserCredentials();327337 try {
@@ -338,12 +348,28 @@ export async function derivePrfSecret(
338348 (credential as (Credential & { getClientExtensionResults?: () => { prf?: PrfResults } }))339349 .getClientExtensionResults?.().prf?.results;340350 if (!results?.first) throw new AuthError("prf-unavailable");341- return new Uint8Array(results.first);351+ return {352+ prfSecret: new Uint8Array(results.first),353+ credential: { id: toBase64Url(rawId(credential)), rpId, portable: isPortable(credential) },354+ };342355 } catch (error) {343356 throw mapError(error);344357 }345358 }346359 360+/**361+ * Derives a credential-bound secret from the passkey via the WebAuthn PRF362+ * extension. The same `salt` on the same authenticator yields the same 32-byte363+ * secret; it never leaves the device. Throws `prf-unavailable` if the client or364+ * authenticator does not return a PRF result — the secret is never faked.365+ */366+export async function derivePrfSecret(367+ salt: BufferSource,368+ dependencies: AuthDependencies = {},369+): Promise<Uint8Array> {370+ return (await prfAssertion(salt, dependencies)).prfSecret;371+}372+347373 /**348374 * Turns a PRF secret into an AES-GCM key for at-rest encryption via HKDF-SHA-256.349375 * Bind unrelated data to different keys by varying `info`.
@@ -420,6 +446,13 @@ const ACCOUNT_SECRET_SALT = new TextEncoder().encode("lofi:account-secret:v1");
420446 */421447 export async function deriveAuthSecret(dependencies: AuthDependencies = {}): Promise<string> {422448 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> {423456 const material = await crypto.subtle.importKey(424457 "raw",425458 prfSecret as BufferSource,
@@ -439,3 +472,19 @@ export async function deriveAuthSecret(dependencies: AuthDependencies = {}): Pro
439472 );440473 return toBase64Url(new Uint8Array(bits));441474 }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+}
M apps/reference/src/_lofi/auth_test.ts +45 -0
@@ -3,6 +3,7 @@ import {
33 AuthError,44 classifyCredentialOrigin,55 decryptAtRest,6+ deriveAccount,67 deriveAtRestKey,78 deriveAuthSecret,89 derivePrfSecret,
@@ -310,3 +311,47 @@ test("deriveAuthSecret throws prf-unavailable when the client returns no PRF res
310311 "prf-unavailable",311312 );312313 });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/runtime.ts +8 -8
@@ -1,6 +1,6 @@
11 import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";22 import { referenceApp } from "../app.ts";3-import { deriveAuthSecret } from "./auth.ts";3+import { AuthError } from "./auth.ts";44 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";55 import { appId, databaseConfig, serverUrl } from "./config.ts";66 import { assertDurableBrowser } from "./device-capabilities.ts";
@@ -54,19 +54,19 @@ function notifyDiagnostics(state = slot()): void {
5454 for (const listener of state.diagnosticListeners) listener();5555 }5656 57-// Device-local (default): a random per-device secret. "device-passkey" makes58-// the passkey the account — the secret is derived from the credential's PRF, so59-// a portable key reconstructs the same account on every device. The derived60-// secret is cached, so only the first boot on a device runs a passkey ceremony.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.6163 async function resolveAccountSecret(): Promise<string> {6264 if (referenceApp.identity !== "device-passkey") {6365 return await BrowserAuthSecretStore.getOrCreateSecret({ appId });6466 }6567 const cached = await BrowserAuthSecretStore.loadSecret({ appId });6668 if (cached) return cached;67- const secret = await deriveAuthSecret();68- await BrowserAuthSecretStore.saveSecret(secret, { appId });69- return secret;69+ throw new AuthError("credential-missing", "Sign in with your passkey to open your account.");7070 }7171 7272 async function createClient(state: RuntimeSlot): Promise<Db> {
A apps/reference/src/_lofi/session.ts +179 -0
@@ -0,0 +1,179 @@
1+/**2+ * Account session for the `device-passkey` identity: the glue between the3+ * WebAuthn primitive in `auth.ts` and the runtime's account secret.4+ *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.11+ *12+ * The small, honest UI that drives this lives in author space13+ * (`src/islands/AccountGate.tsx`) — this module is the framework side.14+ *15+ * @module16+ */17+18+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";30+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. */42+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). */50+ 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;55+};56+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+ }82+}83+84+function clearProfile(): void {85+ try {86+ localStorage?.removeItem(profileKey);87+ } catch {88+ // Nothing to clean up if storage is unavailable.89+ }90+}91+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();132+}133+134+/**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.139+ */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+ );150+}151+152+/**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.156+ */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);161+}162+163+/**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.167+ */168+export async function signOut(): Promise<Session> {169+ await shutdownRuntime();170+ await BrowserAuthSecretStore.clearSecret({ appId });171+ clearProfile();172+ globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));173+ return await readSession();174+}175+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";179+}
M apps/reference/src/app.ts +11 -6
@@ -6,13 +6,18 @@ export const referenceApp = {
66 databaseName: "lofi-prototype",77 schema,88 storage: "durable" as const,9- // "device-local" gives each device its own random account. "device-passkey"10- // makes a portable passkey the account (derived from its PRF), so the same key11- // reaches the same account on any device — see docs/auth-identity.md.12- identity: "device-local" as "device-local" | "device-passkey",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",1314 // Hostnames you have committed to keeping stable, where a device passkey may be14- // enrolled (exact, or a `*.` suffix pattern). A passkey binds to its origin, so15- // enrollment is refused anywhere else. Add yours before shipping device auth.15+ // 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.1619 credentialOrigins: [] as readonly string[],1720 sync: { adapter: "jazz" as const },21+ // Source/home link shown in the starter footer. Point it at your own repo.22+ repositoryUrl: "https://github.com/FelineStateMachine/lofi",1823 };
A apps/reference/src/islands/AccountGate.tsx +198 -0
@@ -0,0 +1,198 @@
1+import { useCallback, useEffect, useState } from "preact/hooks";2+import { AuthError } from "../_lofi/auth.ts";3+import {4+ createPasskeyAccount,5+ readSession,6+ type Session,7+ signInWithPasskey,8+ signOut,9+} from "../_lofi/session.ts";10+11+/**12+ * Author-owned auth example. With `identity: "device-passkey"` (the default),13+ * the passkey *is* the account: signing in derives the Jazz account secret from14+ * the key, so the same passkey opens the same data on every device it reaches.15+ *16+ * This whole island is one pattern — gate the app on a user-gesture sign-in and17+ * show which key you are signed in with. It uses only the primitives in18+ * `src/_lofi/session.ts`; nothing under `src/_lofi/` needs to change. Delete it19+ * (and set `identity: "device-local"`) if you do not want passkey accounts.20+ */21+22+// Turns an AuthError code into a sentence a person can act on.23+function describe(error: unknown): string {24+ if (error instanceof AuthError) {25+ switch (error.code) {26+ case "cancelled":27+ return "Passkey prompt dismissed. Try again when you are ready.";28+ case "prf-unavailable":29+ return "This browser or key cannot derive an account (no WebAuthn PRF). Try a platform passkey in a current browser.";30+ case "origin-rejected":31+ return error.message;32+ case "unsupported":33+ return "This browser does not support passkeys.";34+ case "credential-missing":35+ return "No passkey was returned. Try again, or create an account.";36+ default:37+ return error.message;38+ }39+ }40+ return error instanceof Error ? error.message : String(error);41+}42+43+export default function AccountGate() {44+ const [session, setSession] = useState<Session | null>(null);45+ const [busy, setBusy] = useState<"create" | "signin" | "signout" | null>(null);46+ const [error, setError] = useState<string | null>(null);47+48+ useEffect(() => {49+ let active = true;50+ void readSession().then((next) => {51+ if (active) setSession(next);52+ });53+ return () => {54+ active = false;55+ };56+ }, []);57+58+ const run = useCallback(59+ (kind: "create" | "signin" | "signout", action: () => Promise<Session>) => {60+ setBusy(kind);61+ setError(null);62+ void action().then((next) => {63+ setSession(next);64+ }, (cause) => {65+ setError(describe(cause));66+ }).finally(() => setBusy(null));67+ },68+ [],69+ );70+71+ // Nothing to sign into under device-local identity; DeviceStatus reports it.72+ if (!session || !session.requiresPasskey) return null;73+74+ const { signedIn, syncing, capability, profile } = session;75+ const prf = capability?.prf ?? "unknown";76+ const origin = capability?.origin;77+ const webAuthn = capability?.webAuthn ?? false;78+ const prfBlocked = prf === "unavailable";79+ const disabled = busy !== null || !webAuthn;80+81+ if (signedIn) {82+ return (83+ <section class="account account-in" aria-labelledby="account-title">84+ <header>85+ <p class="eyebrow">Account</p>86+ <h2 id="account-title">Signed in</h2>87+ </header>88+ <dl>89+ <div>90+ <dt>Key</dt>91+ <dd>{profile?.label ?? "passkey"}</dd>92+ </div>93+ <div>94+ <dt>Bound to</dt>95+ <dd>{profile?.rpId ?? origin?.rpId ?? "this origin"}</dd>96+ </div>97+ <div>98+ <dt>Portability</dt>99+ <dd>100+ {profile101+ ? (profile.portable ? "roams across your devices" : "this device only")102+ : "unknown"}103+ </dd>104+ </div>105+ <div>106+ <dt>Sync</dt>107+ <dd>{syncing ? "syncing to your account" : "local-only"}</dd>108+ </div>109+ </dl>110+ <button111+ type="button"112+ class="account-secondary"113+ disabled={busy !== null}114+ onClick={() => run("signout", signOut)}115+ >116+ {busy === "signout" ? "Signing out…" : "Sign out"}117+ </button>118+ <p>119+ Enable passkey identity in your own app in three steps: (1) set{" "}120+ <code>identity: "device-passkey"</code> in <code>src/app.ts</code>{" "}121+ (the default); (2) list your permanent hostname(s) in <code>credentialOrigins</code>{" "}122+ before shipping — leave it empty and the served origin is trusted; (3) render this{" "}123+ <code>AccountGate</code> island and drive sign-in with <code>createPasskeyAccount</code> /124+ {" "}125+ <code>signInWithPasskey</code> from <code>src/_lofi/session.ts</code>.126+ </p>127+ </section>128+ );129+ }130+131+ return (132+ <section class="account account-out" aria-labelledby="account-title">133+ <header>134+ <p class="eyebrow">Account</p>135+ <h2 id="account-title">Sign in with a passkey</h2>136+ </header>137+ <p>138+ Your passkey is your account. Create one, or sign in with an existing one — the same key139+ reaches the same data on every device it can.140+ </p>141+ <div class="account-actions">142+ <button143+ type="button"144+ disabled={disabled}145+ onClick={() => run("create", () => createPasskeyAccount())}146+ >147+ {busy === "create" ? "Creating…" : "Create passkey account"}148+ </button>149+ <button150+ type="button"151+ class="account-secondary"152+ disabled={disabled}153+ onClick={() => run("signin", signInWithPasskey)}154+ >155+ {busy === "signin" ? "Signing in…" : "Sign in with existing passkey"}156+ </button>157+ </div>158+ {error && <p class="account-error" role="alert">{error}</p>}159+ <dl class="account-caps">160+ <div>161+ <dt>Passkeys</dt>162+ <dd>{webAuthn ? "supported" : "unsupported in this browser"}</dd>163+ </div>164+ <div>165+ <dt>PRF (account derivation)</dt>166+ <dd>167+ {prf === "available"168+ ? "available"169+ : prf === "not-reported"170+ ? "not reported — will be tried"171+ : prf === "unavailable"172+ ? "unavailable"173+ : "unknown until you try"}174+ </dd>175+ </div>176+ {origin && (177+ <div>178+ <dt>Origin ({origin.rpId || "unknown"})</dt>179+ <dd>{origin.status}{origin.status !== "stable" ? ` — ${origin.action}` : ""}</dd>180+ </div>181+ )}182+ </dl>183+ {prfBlocked && (184+ <p class="account-note">185+ PRF is unavailable here, so account derivation cannot run — sign-in will report it rather186+ than invent a key.187+ </p>188+ )}189+ {origin?.status === "unverified" && (190+ <p class="account-note">191+ This origin is trusted by default so the app works wherever it is served, but a passkey192+ enrolled here breaks if the host changes. Pin a permanent host in{" "}193+ <code>credentialOrigins</code> (in <code>src/app.ts</code>) before shipping.194+ </p>195+ )}196+ </section>197+ );198+}
M apps/reference/src/islands/TaskList.tsx +14 -2
M apps/reference/src/islands/use-tasks.ts +13 -1
@@ -2,6 +2,7 @@ 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";56 import type { TableSnapshot } from "../_lofi/table-store.ts";67 78 /**
@@ -24,6 +25,11 @@ const initial: TableSnapshot<Task> = {
2425 2526 export function useTasks() {2627 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);2733 2834 useEffect(() => {2935 let cancelled = false;
@@ -34,6 +40,7 @@ export function useTasks() {
3440 unsubscribe?.();3541 unsubscribe = undefined;3642 setSnapshot(initial);43+ setSignedOut(false);3744 void getRuntime().then((runtime) => {3845 if (cancelled || generation !== connectionGeneration) return;3946 const store = runtime.store(tasksTable);
@@ -42,6 +49,11 @@ export function useTasks() {
4249 update();4350 }, (error) => {4451 if (cancelled || generation !== connectionGeneration) return;52+ if (isSignedOut(error)) {53+ setSignedOut(true);54+ setSnapshot(initial);55+ return;56+ }4557 setSnapshot({4658 ...initial,4759 status: "error",
@@ -70,5 +82,5 @@ export function useTasks() {
7082 await (await store()).update(id, { completed });7183 }, [store]);7284 73- return { ...snapshot, tasks: snapshot.rows, create, setCompleted };85+ return { ...snapshot, signedOut, tasks: snapshot.rows, create, setCompleted };7486 }
M apps/reference/src/pages/index.astro +6 -7
M apps/reference/src/styles/global.css +92 -5
@@ -93,6 +93,31 @@ h1 {
9393 margin: 0.25rem 0 0;9494 }9595 96+.device-status h2 {97+ margin-bottom: 0.5rem;98+}99+100+.device-category {101+ border-top: 1px solid #3b594c;102+ margin-top: 1.25rem;103+ padding-top: 1rem;104+}105+106+.device-category h3 {107+ font-size: 1.05rem;108+ margin: 0 0 0.15rem;109+}110+111+.device-hook {112+ color: #9bc9b1;113+ font-size: 0.8rem;114+ margin: 0 0 0.75rem;115+}116+117+.device-hook code {118+ color: #d9efe2;119+}120+96121 label {97122 display: block;98123 font-weight: 750;
@@ -176,18 +201,76 @@ ul {
176201 text-decoration: line-through;177202 }178203 179-.identity-note {204+.account {180205 background: #1d2b25;181206 border: 1px solid #3b594c;182207 border-radius: 0.7rem;183208 color: #bed0c6;184209 line-height: 1.6;185- margin-top: 1rem;186- padding: 1rem 1.4rem;210+ margin-top: 3rem;211+ padding: 1.4rem;187212 }188213 189-.identity-note strong {190- color: #eff8f2;214+.account-out {215+ border-color: #68927e;216+}217+218+.account h2 {219+ margin: 0.25rem 0 1rem;220+}221+222+.account-actions {223+ display: flex;224+ flex-wrap: wrap;225+ gap: 0.6rem;226+ margin: 0.5rem 0 0.25rem;227+}228+229+.account-secondary {230+ background: transparent;231+ color: #d9efe2;232+}233+234+.account button:disabled {235+ cursor: not-allowed;236+ opacity: 0.55;237+}238+239+.account-error {240+ color: #f6b8b0;241+}242+243+.account-note {244+ color: #9bc9b1;245+ font-size: 0.9rem;246+}247+248+.account dl {249+ display: grid;250+ gap: 0.5rem;251+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));252+ margin: 0.5rem 0 0;253+}254+255+.account dl div {256+ background: #18231f;257+ padding: 0.75rem;258+}259+260+.account dt {261+ color: #9bc9b1;262+ font-size: 0.75rem;263+ font-weight: 800;264+ text-transform: uppercase;265+}266+267+.account dd {268+ margin: 0.25rem 0 0;269+ overflow-wrap: anywhere;270+}271+272+.account-caps dd {273+ color: #d9efe2;191274 }192275 193276 footer {
@@ -195,6 +278,10 @@ footer {
195278 margin-top: 2rem;196279 }197280 281+footer a {282+ color: #99e5bd;283+}284+198285 @media (max-width: 40rem) {199286 main {200287 padding: 2.5rem 1rem;
M docs/auth-identity.md +46 -24
@@ -1,11 +1,12 @@
11 # The key is the account22 3-lofi's optional identity model: **a passkey _is_ the account.** The Jazz account secret is derived3+lofi's default identity model: **a passkey _is_ the account.** The Jazz account secret is derived44 from the credential, so the account lives wherever the key lives — and moves between devices exactly55 as far as the key does.66 7-This is opt-in. The default identity is `device-local` (each device gets its own random account).8-Set `identity: "device-passkey"` in `src/app.ts` to switch.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).910 1011 ## How it works1112
@@ -19,15 +20,23 @@ account secret = base64url( HKDF-SHA-256( PRF(passkey, fixed-salt) ) )
1920 The same credential always derives the same account. No account material is stored on any server; it2021 is a pure function of the key.2122 22-### Boot flow (`device-passkey`)23+### Boot flow (`device-passkey`, the default)2324 24-- **First boot on a device** → passkey ceremony → derive the secret → hand it to Jazz → cache it25- locally (`BrowserAuthSecretStore.saveSecret`).26-- **Return boots** → the cached secret is used; no ceremony. "Identity never waits" still holds for27- the common path.28-- **A new device** → the same portable key → the same PRF → the same secret → the same Jazz account29- → your data syncs down and decrypts. Jazz already E2E-encrypts synced data keyed on the account,30- so nothing extra is needed for confidentiality.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.37+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.3140 3241 ## Portable vs device-bound — and telling the user which they chose3342
@@ -57,27 +66,40 @@ can see which account a key unlocks.
5766 5867 - **Feature-detected.** `getAuthCapability()` reports `webAuthn` + `prf`; if PRF is unavailable,5968 derivation throws `prf-unavailable` rather than fabricating a secret.60-- **Origin-gated.** Enrollment and derivation refuse unless the origin is `stable` (an61- author-committed hostname in `credentialOrigins`), because a passkey silently breaks if its origin62- changes.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 trust6375 64-## Enabling it76+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**:6579 6680 ```ts6781 // src/app.ts68-identity: "device-passkey",69-credentialOrigins: ["app.example.com"], // your committed-stable hostname(s)82+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 shipping7084 ```7185 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.90+91+## Using the primitives directly92+93+The default `device-passkey` wiring is done for you by `src/_lofi/session.ts` and the AccountGate94+island. To drive it yourself:95+7296 ```ts73-import { deriveAuthSecret, enrollDeviceCredential, getAuthCapability } from "./_lofi/auth.ts";97+import { deriveAccount, getAuthCapability } from "./_lofi/auth.ts";7498 7599 const { prf, origin } = await getAuthCapability();76-if (prf === "available" && origin.status === "stable") {77- const cred = await enrollDeviceCredential({ label: "work account" });78- // cred.portable === true → the account will travel with this key79- const secret = await deriveAuthSecret(); // the Jazz account secret100+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 key80104 }81105 ```82-83-The runtime does this wiring for you when `identity: "device-passkey"`.
M package/commands/dev.ts +3 -1
M package/starter_template.ts +2 -0
M package/testdata/starter.snapshot.json +11 -9
@@ -7,13 +7,13 @@
77 "public/manifest.webmanifest": "7d492285fd982e6e68fa04f12d78cd4f0bef4343f0408e314f1582029f3ea407",88 "public/sw.js": "6fa9f4c4b5ef7baabd48256a91520c766a4ffaf75c261686384d6d13e663d0c2",99 "README.md": "6a116909a8e9a92a4d48932d2d9672882171cfe1eb1d9d7caa8d36a6e4af8348",10- "src/_lofi/auth_test.ts": "700520bce0bb0b238b8f97edf54ae0a75898e3011cdce7035c6dd8c47aa38921",11- "src/_lofi/auth.ts": "b726b0c7cfec458d7348c05c8d47f3e15d50b146c577845c0f4cea3eb8d0c29b",10+ "src/_lofi/auth_test.ts": "ceaacbb00aa61c4ceea44340dfe4cb68baecf1415b733a21ab45c0ea6c03a0bd",11+ "src/_lofi/auth.ts": "a26e5e515a298ea300ffc88caff807f1d318da113dffd8075b7caf5cdf68743e",1212 "src/_lofi/boot.ts": "0f813fbf75d0ba420dcf893e2762ec84be75869b70ef4f197a367c691773474b",1313 "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",1414 "src/_lofi/device-capabilities_test.ts": "be099ef5f0b3623dc2768b5b588f7e0eb313fea8cf31e4d86b3a52ed1f85cd5a",1515 "src/_lofi/device-capabilities.ts": "6911fbe8ce93cdd79531982fdafd505202141985baec874e4dd965b02e6115d3",16- "src/_lofi/DeviceStatus.tsx": "50443e3411519029e679b9e8d94f11ffcac1fc06074ae05f25d31080f1fb4e8a",16+ "src/_lofi/DeviceStatus.tsx": "876482b5991e3c5398d92c1452323d330d4765963b38e7ceae394f481861be4e",1717 "src/_lofi/diagnostics.ts": "abb2a5f8e4d6b8ab1a86d475db6c374aecf4580b534ba9d7d850e1dbb29bb7f8",1818 "src/_lofi/foreground-recovery_test.ts": "a5081aecaa70b71c63d721a0875f4d2b3898a49879eb8045c8e74baf0d867444",1919 "src/_lofi/foreground-recovery.ts": "a5ed7a65b9454770de62a4ba8cdac7b0d1a867ec3c703049d2c103791c4d0049",
@@ -25,7 +25,8 @@
2525 "src/_lofi/pwa.ts": "9ee6958a63857bb87f9afb58595eb138ef0865fd7c8a4014ac8f90f36813f39a",2626 "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",2727 "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",28- "src/_lofi/runtime.ts": "c11c7fa1fc340c8ec1f935a255dc76174b8dfe4a26cb92aa92eac0a7f6e0b8be",28+ "src/_lofi/runtime.ts": "58975d41c648de6ecddeee11a1755a02aadc9490f5c609a58bce9f7fe540c6a6",29+ "src/_lofi/session.ts": "4ae10c831c89e11e3be7625901b6ace5eeafa6f3403fe92c8c882ee12eb71195",2930 "src/_lofi/Shell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",3031 "src/_lofi/table-store_test.ts": "1b8cfd06192b9c0daacc8a8c7811b691338f4ef459b209b3830fb87a0a039955",3132 "src/_lofi/table-store.ts": "7ad445cc34c11e0cab3afcf1cde097345f1a53f2583da6c7fd00c7cb3e082df8",
@@ -33,14 +34,15 @@
3334 "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",3435 "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",3536 "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",36- "src/app.ts": "fa5068957b68a78d14a11d2bc48b130fc8e093bfa0865d1eabe361e3ef310ff5",37+ "src/app.ts": "c0ddd4f1994c19ca49de5a7c545b9306d9dbe8cfc7579bd9c71d6459965764f3",3738 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",38- "src/islands/TaskList.tsx": "2030161deeaebfa2c33ae4df3691909f1f5ff72a49a906d8dc8154026808c467",39- "src/islands/use-tasks.ts": "d2a9afa80590d4f38db7cfe0da37e6b7b17c00c1a73d82e06b800f97b5d609af",40- "src/pages/index.astro": "ac695d5729b3cc8e264b873d3403bc5a6c4eb36ffdf583584b9a60bec933f5b1",39+ "src/islands/AccountGate.tsx": "50e7cd25920989caf587fe94f5af143b2dfce7142c62af3e94adb37dc0fca682",40+ "src/islands/TaskList.tsx": "81d7fc95248baf4f51e1d53374d7b4700bf165049e846fdcc2694cbeb873652c",41+ "src/islands/use-tasks.ts": "95573b22d5dd1e0e8303ae4536df9a4f0cb27f0c1243aa6c70a6366cc6b7530f",42+ "src/pages/index.astro": "9c1a7446045fb89acd2a0f0c3bad89244871556fb39ddf2669b4a2a495a8208f",4143 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",4244 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",43- "src/styles/global.css": "0e66a21e9b83cc115f01567ab8a87f949ce7fff8e42a3c3dc2efdd70cfffb706",45+ "src/styles/global.css": "b61e6ab48bf483c946fec010be5a099c74e71dd752d41262668f76f6e82f1cda",4446 "tests/auth_e2e_test.ts": "87dc3eb441eeaa96fc82b15df90bf4108096506217e5bfae907b483a3e75b4bf",4547 "tests/author-boundary_test.ts": "529bbd81977294398f4c48a3616d10cdcf0fe5f7c83b705365ad80124ab1a0f1",4648 "tests/convergence_e2e_test.ts": "71ec0ac935c25ff0722905e5b195c730f32a3b886dd4172e11d9e0e80f19cb38",