clone

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

fix(sync): authenticate PoP preflights and align demo notices (#186)

FelineStateMachine committed 90cfe8d
Use proof-of-possession connect URLs for store-status checks during enrollment and boot. Migrate the demo overlay to durable notices and improve recovery/passkey diagnostics.
commit
90cfe8deb808f7513f15703f0e04df581b33e5bd
parent
a007bfdabad7bcbafaaa08b00f1e5d0c85ccf45d
author
FelineStateMachine <dami.etoile@gmail.com>
18 files changed, +279 -85
Mapps/demo/overlay/src/islands/IncidentBoard.tsx9+--
Mapps/demo/overlay/src/islands/use-incidents.ts52+++------------
Mapps/reference/src/islands/AccountGate.tsx2+-
Mpackage/preact/DeviceStatus.tsx36++++++++++-
Mpackage/preact/DeviceStatus_test.tsx21++++++-
Mpackage/runtime/data-sink.ts7+-
Mpackage/runtime/mod.ts2+-
Mpackage/runtime/passkey-recovery.ts3+-
Mpackage/runtime/passkey-recovery_test.ts6++
Mpackage/runtime/recovery.ts33++++++----
Mpackage/runtime/recovery_test.ts10+++-
Mpackage/runtime/runtime.ts20+++---
Mpackage/runtime/session.ts28+++++++-
Mpackage/runtime/session_test.ts106++++++++++++++++++++++++++++++
Mpackage/runtime/store-status_test.ts10+++-
Mpackage/starter/src/islands/AccountGate.tsx.txt2+-
Mpackage/testdata/starter.snapshot.json2+-
Mtools/demo_overlay_test.ts15++++
M apps/demo/overlay/src/islands/IncidentBoard.tsx +2 -7
M apps/demo/overlay/src/islands/use-incidents.ts +11 -41
@@ -1,4 +1,4 @@
1-import { useCallback, useEffect, useState } from "preact/hooks";1+import { useCallback, useState } from "preact/hooks";22 import type { RowOf, WriteHandle } from "@nzip/lofi";33 import { useLiveQuery, useWrite } from "@nzip/lofi/preact";44 import { s } from "@nzip/lofi/schema";
@@ -18,57 +18,27 @@ export type Incident = RowOf<typeof incidentsTable>;
1818 export type Severity = Incident["severity"];1919 export type IncidentStatus = Incident["status"];2020 21-/** A one-line consequence or compensation surfaced to the UI. */22-export type IncidentNotice = { kind: "synced" | "rejected"; text: string };23-24-// A tiny author-owned notice channel: effect handlers run outside any25-// component, so they publish through module state and hooks subscribe.26-let notice: IncidentNotice | null = null;27-const noticeListeners = new Set<() => void>();28-29-function publishNotice(next: IncidentNotice | null): void {30- notice = next;31- for (const listener of [...noticeListeners]) listener();32-}33-3421 /**3522 * The reporting verb. Its effect units are declared once, here: the3623 * consequence runs when the store confirms the row, the compensation runs if3724 * a stale-policy write is denied, even if the app restarted in between.3825 */3926 export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {40- effects: [s.log("incident-reported")],41- onSynced: (incident) => {42- publishNotice({43- kind: "synced",44- text: `"${incident.title ?? "Incident"}" confirmed by the store`,45- });46- },47- onRejected: (incident) => {48- // The engine already rolled the denied row back out of local reads; this49- // compensates what the user was told.50- publishNotice({51- kind: "rejected",52- text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,53- });54- },27+ effects: [28+ s.log("incident-reported"),29+ s.notice<Incident>({30+ synced: (incident) => `"${incident.title ?? "Incident"}" confirmed by the store`,31+ // The engine already rolled a denied insert out of local reads; this32+ // durable notice compensates what the user was told, even after reload.33+ rejected: (incident) =>34+ `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,35+ }),36+ ],5537 });5638 5739 /** Moving an incident between states is a plain verb: same lifecycle. */5840 export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));5941 60-/** Subscribes to the latest effect notice; `null` until one is published. */61-export function useIncidentNotice(): IncidentNotice | null {62- const [current, setCurrent] = useState<IncidentNotice | null>(notice);63- useEffect(() => {64- const listener = () => setCurrent(notice);65- noticeListeners.add(listener);66- listener();67- return () => void noticeListeners.delete(listener);68- }, []);69- return current;70-}71-7242 export function useIncidents() {7343 const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);7444 const [lastWrite, setLastWrite] = useState<WriteHandle<Incident> | null>(null);
M apps/reference/src/islands/AccountGate.tsx +1 -1
M package/preact/DeviceStatus.tsx +34 -2
@@ -2,6 +2,7 @@ import type { VNode } from "preact";
22 import { useEffect, useState } from "preact/hooks";33 // Package-owned optional diagnostics UI.44 import { useDeviceCapabilities } from "./use-device-capabilities.ts";5+import { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";56 import { settleUiMutation } from "../runtime/ui-mutation.ts";67 import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";78 import { PwaActions } from "./PwaActions.tsx";
@@ -30,6 +31,20 @@ function Row({ label, value }: { label: string; value: string }): VNode {
3031 3132 const available = (present: boolean) => (present ? "available" : "missing");3233 34+/** One-line credential-origin verdict that distinguishes API support from deployability. */35+export function describeCredentialOrigin(origin: CredentialOriginReport): string {36+ switch (origin.status) {37+ case "stable":38+ return `stable — ${origin.rpId}`;39+ case "local-only":40+ return `local development only — ${origin.rpId}`;41+ case "unverified":42+ return `unverified — ${origin.rpId}`;43+ case "blocked":44+ return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";45+ }46+}47+3348 /**3449 * The one-line Data sync verdict. The blocked dispositions are first-class —3550 * the report must say *why* nothing is syncing, not merely that it is not:
@@ -79,6 +94,7 @@ export function DeviceStatus(): VNode {
7994 const [pwa, setPwa] = useState<PwaState>(getPwaState());8095 const [session, setSession] = useState<Session | null>(null);8196 const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());97+ const [credentialOrigin, setCredentialOrigin] = useState<CredentialOriginReport | null>(null);8298 8399 useEffect(() => subscribePwaState(setPwa), []);84100 useEffect(
@@ -92,6 +108,15 @@ export function DeviceStatus(): VNode {
92108 globalThis.addEventListener(runtimeRecreatedEvent, refresh);93109 return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);94110 }, []);111+ useEffect(() => {112+ let active = true;113+ void getAuthCapability().then((capability) => {114+ if (active) setCredentialOrigin(capability.origin);115+ });116+ return () => {117+ active = false;118+ };119+ }, []);95120 96121 if (!report) return <p class="device-status">Checking device capabilities…</p>;97122
@@ -218,9 +243,16 @@ export function DeviceStatus(): VNode {
218243 <dl>219244 <Row label="Identity" value="local-first account" />220245 <Row label="Backup" value={backupState} />221- <Row label="WebAuthn" value={available(report.webAuthn)} />222- <Row label="PRF (at-rest key)" value={report.prf} />246+ <Row label="WebAuthn API" value={available(report.webAuthn)} />247+ <Row label="PRF API (at-rest key)" value={report.prf} />248+ <Row249+ label="Credential origin"250+ value={credentialOrigin ? describeCredentialOrigin(credentialOrigin) : "checking"}251+ />223252 </dl>253+ {credentialOrigin && credentialOrigin.status !== "stable" && (254+ <p>{credentialOrigin.action}</p>255+ )}224256 </div>225257 226258 <div class="device-category">
M package/preact/DeviceStatus_test.tsx +20 -1
M package/runtime/data-sink.ts +5 -2
M package/runtime/mod.ts +1 -1
M package/runtime/passkey-recovery.ts +2 -1
M package/runtime/passkey-recovery_test.ts +6 -0
M package/runtime/recovery.ts +20 -13
@@ -21,32 +21,39 @@ import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";
2121 /** The number of words in a lofi recovery phrase. */2222 export const RECOVERY_PHRASE_WORDS = 24;2323 24+/** Stable recovery-phrase failure categories for actionable UI guidance. */25+export type RecoveryErrorCode =26+ | "invalid-length"27+ | "invalid-word"28+ | "invalid-checksum"29+ | "invalid-secret";30+31+const MESSAGES: Record<RecoveryErrorCode, string> = {32+ "invalid-length":33+ `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,34+ "invalid-word": "One of the words is not in the recovery word list — check your spelling.",35+ "invalid-checksum":36+ "That phrase is not a valid recovery phrase — re-check the words and their order.",37+ "invalid-secret": "The account secret could not be encoded as a recovery phrase.",38+};39+2440 /** A precise, non-leaking failure reason for a recovery-phrase operation. */2541 export class RecoveryError extends Error {2642 /** Stable error class name for diagnostics and error boundaries. */2743 override readonly name = "RecoveryError";2844 /** Actionable category that callers can map to recovery guidance. */29- readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";45+ readonly code: RecoveryErrorCode;3046 /** Creates a phrase error without retaining the submitted phrase. */31- constructor(code: RecoveryError["code"], message?: string) {32- super(message ?? `Recovery phrase operation failed: ${code}.`);47+ constructor(code: RecoveryErrorCode, message?: string) {48+ super(message ?? MESSAGES[code]);3349 this.code = code;3450 }3551 }3652 37-const MESSAGES: Record<RecoveryError["code"], string> = {38- "invalid-length":39- `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,40- "invalid-word": "One of the words is not in the recovery word list — check your spelling.",41- "invalid-checksum":42- "That phrase is not a valid recovery phrase — re-check the words and their order.",43- "invalid-secret": "The account secret could not be encoded as a recovery phrase.",44-};45-4653 function mapError(error: unknown): RecoveryError {4754 if (error instanceof RecoveryError) return error;4855 if (error instanceof RecoveryPhraseError) {49- const code = error.code as RecoveryError["code"];56+ const code = error.code as RecoveryErrorCode;5057 return new RecoveryError(code, MESSAGES[code] ?? undefined);5158 }5259 return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);
M package/runtime/recovery_test.ts +9 -1
M package/runtime/runtime.ts +11 -9
@@ -294,15 +294,6 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
294294 }295295 notifyDiagnostics(state);296296 }297- // The store preflight rides alongside database creation, never in front298- // of it: local-first boot must not wait on the network. resolveStoreStatus299- // maps every failure and timeout to a diagnostic value, so this only300- // records state — a schema-less or drifted store surfaces here at boot301- // instead of as a hanging first write, and is never repaired from here.302- void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {303- state.diagnostics.storeStatus = status;304- notifyDiagnostics(state);305- });306297 // A possession-bound sink proves the device key before connecting: the307298 // exchange mints a connect token the sync client carries as a path308299 // segment. A failed exchange (node restart, revocation, key loss) boots
@@ -325,6 +316,17 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
325316 keyPair,326317 }) ?? undefined;327318 }319+ // The store preflight rides alongside database creation, never in front320+ // of it: local-first boot must not wait on the network. A possession-bound321+ // sink must use the authenticated connect URL minted above; its bare322+ // ticket URL correctly rejects even metadata reads. resolveStoreStatus323+ // maps every remaining failure and timeout to a diagnostic value, so this324+ // only records state and never repairs the store.325+ const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;326+ void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {327+ state.diagnostics.storeStatus = status;328+ notifyDiagnostics(state);329+ });328330 const db = await createDb(329331 databaseConfig(330332 secret,
M package/runtime/session.ts +25 -3
@@ -48,7 +48,12 @@ import {
4848 secretFingerprint,4949 SyncOwnerError,5050 } from "./sync-owner.ts";51-import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";51+import {52+ completePopExchange,53+ type DevicePublicKey,54+ exportDevicePublicKey,55+ getOrCreatePopKeyPair,56+} from "./pop.ts";5257 import { holdProvisionCapability } from "./provision.ts";5358 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";5459 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";
@@ -437,11 +442,13 @@ export async function performTicketEnrollment(
437442 ): Promise<Session> {438443 // Only a provision-scoped ticket reaches the scope-down exchange, so only439444 // then is there a binding to offer.445+ let deviceKeyPair: CryptoKeyPair | undefined;440446 let devicePublicKey: DevicePublicKey | undefined;441447 const parsed = parseSyncTicket(ticket);442448 if (parsed?.scope === "provision") {443449 try {444- devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));450+ deviceKeyPair = await getOrCreatePopKeyPair(parsed.appId);451+ devicePublicKey = await exportDevicePublicKey(deviceKeyPair);445452 } catch {446453 // No usable key custody in this context; the exchange still derives a447454 // ticket, held as a bearer credential exactly as before.
@@ -450,6 +457,21 @@ export async function performTicketEnrollment(
450457 const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);451458 const previous = readDeclaredSink();452459 const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);460+ // A PoP-bound derived ticket rejects every bare request, including the461+ // metadata preflight. Prove possession first and ask store-status through462+ // the short-lived connect URL, just as the managed runtime does for sync.463+ // Without this exchange a healthy store looks unreachable, while a real464+ // no-schema refusal can be lost and enrollment incorrectly kept.465+ let preflightServerUrl = declared.serverUrl;466+ if (split.pop && deviceKeyPair) {467+ preflightServerUrl = await completePopExchange({468+ serverUrl: declared.serverUrl,469+ appId: declared.appId,470+ ticketId: split.pop.ticketId,471+ keyPair: deviceKeyPair,472+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),473+ }) ?? declared.serverUrl;474+ }453475 // The preflight decides whether enrollment is kept: a store that answers454476 // with a definite refusal rolls the declaration back before anything else455477 // (election, provision custody) observes it. An unreachable store is not a
@@ -457,7 +479,7 @@ export async function performTicketEnrollment(
457479 // the warning recorded where status surfaces read it.458480 const status = await resolveStoreStatus({459481 connect: true,460- sink: { serverUrl: declared.serverUrl },482+ sink: { serverUrl: preflightServerUrl },461483 ...(deps.preflight ? { preflight: deps.preflight } : {}),462484 ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),463485 });
M package/runtime/session_test.ts +106 -0
@@ -288,6 +288,112 @@ test(
288288 }),289289 );290290 291+test(292+ "a PoP-bound provision enrollment preflights through its authenticated connect URL",293+ withCleanSyncState(async () => {294+ const derivedSecret = "d".repeat(43);295+ const connectSecret = "c".repeat(43);296+ const requestedUrls: string[] = [];297+ const fetcher: typeof fetch = (input, init) => {298+ const url = String(input);299+ requestedUrls.push(url);300+ if (url.endsWith("/derive-sync-ticket")) {301+ return Promise.resolve(302+ new Response(303+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),304+ { status: 200 },305+ ),306+ );307+ }308+ if (url.endsWith("/pop/challenge")) {309+ return Promise.resolve(310+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {311+ status: 200,312+ }),313+ );314+ }315+ if (url.endsWith("/pop/answer") && init?.method === "POST") {316+ return Promise.resolve(317+ new Response(JSON.stringify({ v: 1, connect: connectSecret }), { status: 200 }),318+ );319+ }320+ throw new Error(`unexpected fetch: ${url}`);321+ };322+ let preflightUrl = "";323+ await performTicketEnrollment(provisionTicket, {324+ fetcher,325+ keyStore: memoryDeviceKeyStore(),326+ preflight: (url) => {327+ preflightUrl = url;328+ return answering("deployed")();329+ },330+ elect: () => Promise.resolve(undefined as never),331+ });332+ assert(333+ preflightUrl ===334+ `http://192.168.1.10:4802/t/${derivedSecret}/c/${connectSecret}`,335+ `store-status must use the PoP-authenticated connect URL (received ${preflightUrl})`,336+ );337+ assert(338+ requestedUrls.some((url) => url.endsWith("/pop/challenge")) &&339+ requestedUrls.some((url) => url.endsWith("/pop/answer")),340+ "enrollment must complete the PoP exchange before preflight",341+ );342+ }),343+);344+345+test(346+ "a no_schema answer through a PoP connect URL still rolls enrollment back",347+ withCleanSyncState(async () => {348+ const fetcher: typeof fetch = (input) => {349+ const url = String(input);350+ if (url.endsWith("/derive-sync-ticket")) {351+ return Promise.resolve(352+ new Response(353+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),354+ { status: 200 },355+ ),356+ );357+ }358+ if (url.endsWith("/pop/challenge")) {359+ return Promise.resolve(360+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {361+ status: 200,362+ }),363+ );364+ }365+ if (url.endsWith("/pop/answer")) {366+ return Promise.resolve(367+ new Response(JSON.stringify({ v: 1, connect: "c".repeat(43) }), { status: 200 }),368+ );369+ }370+ throw new Error(`unexpected fetch: ${url}`);371+ };372+ let thrown: unknown;373+ try {374+ await performTicketEnrollment(provisionTicket, {375+ fetcher,376+ keyStore: memoryDeviceKeyStore(),377+ preflight: answering("no_schema"),378+ elect: () => Promise.reject(new Error("elect must not run")),379+ });380+ } catch (error) {381+ thrown = error;382+ }383+ assert(384+ isSyncEnrollmentError(thrown) && thrown.code === "no_schema",385+ `the authenticated no-schema answer must retain the enrollment safety gate (received ${386+ thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)387+ })`,388+ );389+ assert(readDeclaredSink() === null, "the PoP-bound sink must be rolled back");390+ assert(391+ !provisionCapabilityStatus().held,392+ "the provision capability must not be held after the refused enrollment",393+ );394+ }),395+);396+291397 test(292398 "electing under a foreign owner is refused; an unclaimed election records the owner",293399 withCleanSyncState(async () => {
M package/runtime/store-status_test.ts +9 -1
M package/starter/src/islands/AccountGate.tsx.txt +1 -1
M package/testdata/starter.snapshot.json +1 -1
M tools/demo_overlay_test.ts +15 -0