Date: Mon, 20 Jul 2026 04:18:31 -0600
Subject: [PATCH 6/6] fix(sync): authenticate PoP preflights and align demo
notices (#186)
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.
---
.../overlay/src/islands/IncidentBoard.tsx | 9 +-
.../demo/overlay/src/islands/use-incidents.ts | 52 ++-------
apps/reference/src/islands/AccountGate.tsx | 2 +-
package/preact/DeviceStatus.tsx | 36 +++++-
package/preact/DeviceStatus_test.tsx | 21 +++-
package/runtime/data-sink.ts | 7 +-
package/runtime/mod.ts | 2 +-
package/runtime/passkey-recovery.ts | 3 +-
package/runtime/passkey-recovery_test.ts | 6 +
package/runtime/recovery.ts | 33 +++---
package/runtime/recovery_test.ts | 10 +-
package/runtime/runtime.ts | 20 ++--
package/runtime/session.ts | 28 ++++-
package/runtime/session_test.ts | 106 ++++++++++++++++++
package/runtime/store-status_test.ts | 10 +-
.../starter/src/islands/AccountGate.tsx.txt | 2 +-
package/testdata/starter.snapshot.json | 2 +-
tools/demo_overlay_test.ts | 15 +++
18 files changed, 279 insertions(+), 85 deletions(-)
diff --git a/apps/demo/overlay/src/islands/IncidentBoard.tsx b/apps/demo/overlay/src/islands/IncidentBoard.tsx
index 392612f..50ca64c 100644
--- a/apps/demo/overlay/src/islands/IncidentBoard.tsx
+++ b/apps/demo/overlay/src/islands/IncidentBoard.tsx
@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
+ Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
@@ -10,7 +11,6 @@ import {
type Incident,
type IncidentStatus,
type Severity,
- useIncidentNotice,
useIncidents,
} from "./use-incidents.ts";
@@ -51,7 +51,6 @@ function openedLabel(value: Incident["openedAt"]): string {
*/
export default function IncidentBoard() {
const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();
- const notice = useIncidentNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [title, setTitle] = useState("");
@@ -107,11 +106,7 @@ export default function IncidentBoard() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
)}
- {notice && (
-
- {notice.text}
-
- )}
+
{COLUMNS.map((column) => {
const rows = incidents.filter((incident) => incident.status === column.status);
diff --git a/apps/demo/overlay/src/islands/use-incidents.ts b/apps/demo/overlay/src/islands/use-incidents.ts
index 0b9e80c..c759f1a 100644
--- a/apps/demo/overlay/src/islands/use-incidents.ts
+++ b/apps/demo/overlay/src/islands/use-incidents.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "preact/hooks";
+import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
@@ -18,57 +18,27 @@ export type Incident = RowOf
;
export type Severity = Incident["severity"];
export type IncidentStatus = Incident["status"];
-/** A one-line consequence or compensation surfaced to the UI. */
-export type IncidentNotice = { kind: "synced" | "rejected"; text: string };
-
-// A tiny author-owned notice channel: effect handlers run outside any
-// component, so they publish through module state and hooks subscribe.
-let notice: IncidentNotice | null = null;
-const noticeListeners = new Set<() => void>();
-
-function publishNotice(next: IncidentNotice | null): void {
- notice = next;
- for (const listener of [...noticeListeners]) listener();
-}
-
/**
* The reporting verb. Its effect units are declared once, here: the
* consequence runs when the store confirms the row, the compensation runs if
* a stale-policy write is denied, even if the app restarted in between.
*/
export const reportIncident = s.mutation("reportIncident", s.insert(incidentsTable), {
- effects: [s.log("incident-reported")],
- onSynced: (incident) => {
- publishNotice({
- kind: "synced",
- text: `"${incident.title ?? "Incident"}" confirmed by the store`,
- });
- },
- onRejected: (incident) => {
- // The engine already rolled the denied row back out of local reads; this
- // compensates what the user was told.
- publishNotice({
- kind: "rejected",
- text: `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
- });
- },
+ effects: [
+ s.log("incident-reported"),
+ s.notice({
+ synced: (incident) => `"${incident.title ?? "Incident"}" confirmed by the store`,
+ // The engine already rolled a denied insert out of local reads; this
+ // durable notice compensates what the user was told, even after reload.
+ rejected: (incident) =>
+ `"${incident.title ?? "Incident"}" was declined by the store and has been removed`,
+ }),
+ ],
});
/** Moving an incident between states is a plain verb: same lifecycle. */
export const setIncidentStatus = s.mutation("setIncidentStatus", s.update(incidentsTable));
-/** Subscribes to the latest effect notice; `null` until one is published. */
-export function useIncidentNotice(): IncidentNotice | null {
- const [current, setCurrent] = useState(notice);
- useEffect(() => {
- const listener = () => setCurrent(notice);
- noticeListeners.add(listener);
- listener();
- return () => void noticeListeners.delete(listener);
- }, []);
- return current;
-}
-
export function useIncidents() {
const query = useLiveQuery(() => incidentsTable.orderBy("openedAt", "desc"), []);
const [lastWrite, setLastWrite] = useState | null>(null);
diff --git a/apps/reference/src/islands/AccountGate.tsx b/apps/reference/src/islands/AccountGate.tsx
index 6e8b66b..cf82c82 100644
--- a/apps/reference/src/islands/AccountGate.tsx
+++ b/apps/reference/src/islands/AccountGate.tsx
@@ -50,7 +50,7 @@ function describe(error: unknown): string {
if (isAuthError(error)) {
switch (error.code) {
case "cancelled":
- return "Passkey prompt dismissed — your recovery phrase was not shown.";
+ return "Passkey verification did not complete — your recovery phrase was not shown.";
case "unsupported":
return "This browser does not support passkeys.";
default:
diff --git a/package/preact/DeviceStatus.tsx b/package/preact/DeviceStatus.tsx
index 1ad20d1..c716303 100644
--- a/package/preact/DeviceStatus.tsx
+++ b/package/preact/DeviceStatus.tsx
@@ -2,6 +2,7 @@ import type { VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
// Package-owned optional diagnostics UI.
import { useDeviceCapabilities } from "./use-device-capabilities.ts";
+import { type CredentialOriginReport, getAuthCapability } from "../runtime/auth.ts";
import { settleUiMutation } from "../runtime/ui-mutation.ts";
import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";
import { PwaActions } from "./PwaActions.tsx";
@@ -30,6 +31,20 @@ function Row({ label, value }: { label: string; value: string }): VNode {
const available = (present: boolean) => (present ? "available" : "missing");
+/** One-line credential-origin verdict that distinguishes API support from deployability. */
+export function describeCredentialOrigin(origin: CredentialOriginReport): string {
+ switch (origin.status) {
+ case "stable":
+ return `stable — ${origin.rpId}`;
+ case "local-only":
+ return `local development only — ${origin.rpId}`;
+ case "unverified":
+ return `unverified — ${origin.rpId}`;
+ case "blocked":
+ return origin.rpId ? `blocked — ${origin.rpId}` : "blocked";
+ }
+}
+
/**
* The one-line Data sync verdict. The blocked dispositions are first-class —
* the report must say *why* nothing is syncing, not merely that it is not:
@@ -79,6 +94,7 @@ export function DeviceStatus(): VNode {
const [pwa, setPwa] = useState(getPwaState());
const [session, setSession] = useState(null);
const [runtimeDiagnostics, setRuntimeDiagnostics] = useState(getRuntimeDiagnostics());
+ const [credentialOrigin, setCredentialOrigin] = useState(null);
useEffect(() => subscribePwaState(setPwa), []);
useEffect(
@@ -92,6 +108,15 @@ export function DeviceStatus(): VNode {
globalThis.addEventListener(runtimeRecreatedEvent, refresh);
return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);
}, []);
+ useEffect(() => {
+ let active = true;
+ void getAuthCapability().then((capability) => {
+ if (active) setCredentialOrigin(capability.origin);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
if (!report) return Checking device capabilities…
;
@@ -218,9 +243,16 @@ export function DeviceStatus(): VNode {
-
-
+
+
+
+ {credentialOrigin && credentialOrigin.status !== "stable" && (
+ {credentialOrigin.action}
+ )}
diff --git a/package/preact/DeviceStatus_test.tsx b/package/preact/DeviceStatus_test.tsx
index 94824bc..056989a 100644
--- a/package/preact/DeviceStatus_test.tsx
+++ b/package/preact/DeviceStatus_test.tsx
@@ -1,4 +1,4 @@
-import { describeSyncState } from "./DeviceStatus.tsx";
+import { describeCredentialOrigin, describeSyncState } from "./DeviceStatus.tsx";
// The report's contract after the sync-state integrity pass: a reader must be
// able to tell *why* nothing is syncing. Each blocked disposition names its
@@ -45,3 +45,22 @@ Deno.test("describeSyncState puts the owner mismatch above store answers", () =>
throw new Error(`owner mismatch was outranked: ${verdict}`);
}
});
+
+Deno.test("credential-origin status does not overstate API support", () => {
+ const local = describeCredentialOrigin({
+ status: "local-only",
+ rpId: "localhost",
+ action: "use the stable HTTPS origin",
+ });
+ if (!local.includes("local development only") || !local.includes("localhost")) {
+ throw new Error(`local credential origin was overstated: ${local}`);
+ }
+ const stable = describeCredentialOrigin({
+ status: "stable",
+ rpId: "demo.lofi.host",
+ action: "keep this hostname",
+ });
+ if (!stable.includes("stable") || !stable.includes("demo.lofi.host")) {
+ throw new Error(`stable credential origin lost its hostname: ${stable}`);
+ }
+});
diff --git a/package/runtime/data-sink.ts b/package/runtime/data-sink.ts
index 259df53..0cb63c8 100644
--- a/package/runtime/data-sink.ts
+++ b/package/runtime/data-sink.ts
@@ -335,16 +335,19 @@ export type SyncTicket = {
const TICKET_PREFIX = "lofisync1.";
const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;
+const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;
/**
- * Whether a server URL carries an app-connect ticket path (`/t/`) and
+ * Whether a server URL carries an app-connect ticket path (`/t/`) or
+ * its PoP-authenticated connect-token form (`/t//c/`) and
* therefore fronts a lofi-node gate, which is what exposes the metadata-only
* store-status endpoint. First-party Jazz servers and open-mode node URLs do
* not match.
*/
export function isTicketServerUrl(serverUrl: string): boolean {
try {
- return TICKET_PATH.test(new URL(serverUrl).pathname);
+ const path = new URL(serverUrl).pathname;
+ return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);
} catch {
return false;
}
diff --git a/package/runtime/mod.ts b/package/runtime/mod.ts
index d6957cc..9585b8e 100644
--- a/package/runtime/mod.ts
+++ b/package/runtime/mod.ts
@@ -208,7 +208,7 @@ export {
type SinkRestoreOutcome,
type SyncTicket,
} from "./data-sink.ts";
-export { RecoveryError } from "./recovery.ts";
+export { RecoveryError, type RecoveryErrorCode } from "./recovery.ts";
export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";
export {
type RowOf,
diff --git a/package/runtime/passkey-recovery.ts b/package/runtime/passkey-recovery.ts
index 41e39ad..cee1390 100644
--- a/package/runtime/passkey-recovery.ts
+++ b/package/runtime/passkey-recovery.ts
@@ -21,7 +21,8 @@ export type RecoverablePasskeyErrorCode =
| "restore-failed";
const messages: Record = {
- cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",
+ cancelled:
+ "A passkey was not created or opened. Nothing on this device was replaced; try again or use the recovery phrase.",
unsupported:
"This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",
"credential-missing":
diff --git a/package/runtime/passkey-recovery_test.ts b/package/runtime/passkey-recovery_test.ts
index 8344714..17bded7 100644
--- a/package/runtime/passkey-recovery_test.ts
+++ b/package/runtime/passkey-recovery_test.ts
@@ -25,6 +25,12 @@ Deno.test("passkey backup errors become actionable non-secret recovery errors",
!mapped.message.includes("vendor detail"),
"vendor detail leaked into public error text",
);
+ if (code === "cancelled") {
+ assert(
+ mapped.message.includes("not created or opened") && !mapped.message.includes("cancelled"),
+ "NotAllowedError guidance must not assume the person dismissed the prompt",
+ );
+ }
}
});
diff --git a/package/runtime/recovery.ts b/package/runtime/recovery.ts
index 49cd7df..bb94a8a 100644
--- a/package/runtime/recovery.ts
+++ b/package/runtime/recovery.ts
@@ -21,32 +21,39 @@ import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";
/** The number of words in a lofi recovery phrase. */
export const RECOVERY_PHRASE_WORDS = 24;
+/** Stable recovery-phrase failure categories for actionable UI guidance. */
+export type RecoveryErrorCode =
+ | "invalid-length"
+ | "invalid-word"
+ | "invalid-checksum"
+ | "invalid-secret";
+
+const MESSAGES: Record = {
+ "invalid-length":
+ `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,
+ "invalid-word": "One of the words is not in the recovery word list — check your spelling.",
+ "invalid-checksum":
+ "That phrase is not a valid recovery phrase — re-check the words and their order.",
+ "invalid-secret": "The account secret could not be encoded as a recovery phrase.",
+};
+
/** A precise, non-leaking failure reason for a recovery-phrase operation. */
export class RecoveryError extends Error {
/** Stable error class name for diagnostics and error boundaries. */
override readonly name = "RecoveryError";
/** Actionable category that callers can map to recovery guidance. */
- readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";
+ readonly code: RecoveryErrorCode;
/** Creates a phrase error without retaining the submitted phrase. */
- constructor(code: RecoveryError["code"], message?: string) {
- super(message ?? `Recovery phrase operation failed: ${code}.`);
+ constructor(code: RecoveryErrorCode, message?: string) {
+ super(message ?? MESSAGES[code]);
this.code = code;
}
}
-const MESSAGES: Record = {
- "invalid-length":
- `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,
- "invalid-word": "One of the words is not in the recovery word list — check your spelling.",
- "invalid-checksum":
- "That phrase is not a valid recovery phrase — re-check the words and their order.",
- "invalid-secret": "The account secret could not be encoded as a recovery phrase.",
-};
-
function mapError(error: unknown): RecoveryError {
if (error instanceof RecoveryError) return error;
if (error instanceof RecoveryPhraseError) {
- const code = error.code as RecoveryError["code"];
+ const code = error.code as RecoveryErrorCode;
return new RecoveryError(code, MESSAGES[code] ?? undefined);
}
return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);
diff --git a/package/runtime/recovery_test.ts b/package/runtime/recovery_test.ts
index 701d4c9..c020fee 100644
--- a/package/runtime/recovery_test.ts
+++ b/package/runtime/recovery_test.ts
@@ -49,12 +49,20 @@ test("fromRecoveryPhrase tolerates messy whitespace and casing", () => {
test("fromRecoveryPhrase rejects a wrong word count with invalid-length", () => {
let code: string | undefined;
+ let message = "";
try {
fromRecoveryPhrase("one two three");
} catch (error) {
- if (error instanceof RecoveryError) code = error.code;
+ if (error instanceof RecoveryError) {
+ code = error.code;
+ message = error.message;
+ }
}
assert(code === "invalid-length", "too few words must raise invalid-length");
+ assert(
+ message.includes(`${RECOVERY_PHRASE_WORDS} words`) && !message.includes("invalid-length"),
+ "the invalid-length error must give person-readable word-count guidance",
+ );
});
test("fromRecoveryPhrase rejects an empty phrase with invalid-length", () => {
diff --git a/package/runtime/runtime.ts b/package/runtime/runtime.ts
index 453c4a4..6d69b30 100644
--- a/package/runtime/runtime.ts
+++ b/package/runtime/runtime.ts
@@ -294,15 +294,6 @@ async function createClient(state: RuntimeSlot): Promise {
}
notifyDiagnostics(state);
}
- // The store preflight rides alongside database creation, never in front
- // of it: local-first boot must not wait on the network. resolveStoreStatus
- // maps every failure and timeout to a diagnostic value, so this only
- // records state — a schema-less or drifted store surfaces here at boot
- // instead of as a hanging first write, and is never repaired from here.
- void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {
- state.diagnostics.storeStatus = status;
- notifyDiagnostics(state);
- });
// A possession-bound sink proves the device key before connecting: the
// exchange mints a connect token the sync client carries as a path
// segment. A failed exchange (node restart, revocation, key loss) boots
@@ -325,6 +316,17 @@ async function createClient(state: RuntimeSlot): Promise {
keyPair,
}) ?? undefined;
}
+ // The store preflight rides alongside database creation, never in front
+ // of it: local-first boot must not wait on the network. A possession-bound
+ // sink must use the authenticated connect URL minted above; its bare
+ // ticket URL correctly rejects even metadata reads. resolveStoreStatus
+ // maps every remaining failure and timeout to a diagnostic value, so this
+ // only records state and never repairs the store.
+ const statusSink = popSink ? { serverUrl: serverUrlOverride ?? popSink.serverUrl } : null;
+ void resolveStoreStatus({ connect: effectiveConnect, sink: statusSink }).then((status) => {
+ state.diagnostics.storeStatus = status;
+ notifyDiagnostics(state);
+ });
const db = await createDb(
databaseConfig(
secret,
diff --git a/package/runtime/session.ts b/package/runtime/session.ts
index 1c4e10a..b4d43e4 100644
--- a/package/runtime/session.ts
+++ b/package/runtime/session.ts
@@ -48,7 +48,12 @@ import {
secretFingerprint,
SyncOwnerError,
} from "./sync-owner.ts";
-import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";
+import {
+ completePopExchange,
+ type DevicePublicKey,
+ exportDevicePublicKey,
+ getOrCreatePopKeyPair,
+} from "./pop.ts";
import { holdProvisionCapability } from "./provision.ts";
import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";
import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";
@@ -437,11 +442,13 @@ export async function performTicketEnrollment(
): Promise {
// Only a provision-scoped ticket reaches the scope-down exchange, so only
// then is there a binding to offer.
+ let deviceKeyPair: CryptoKeyPair | undefined;
let devicePublicKey: DevicePublicKey | undefined;
const parsed = parseSyncTicket(ticket);
if (parsed?.scope === "provision") {
try {
- devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));
+ deviceKeyPair = await getOrCreatePopKeyPair(parsed.appId);
+ devicePublicKey = await exportDevicePublicKey(deviceKeyPair);
} catch {
// No usable key custody in this context; the exchange still derives a
// ticket, held as a bearer credential exactly as before.
@@ -450,6 +457,21 @@ export async function performTicketEnrollment(
const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);
const previous = readDeclaredSink();
const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);
+ // A PoP-bound derived ticket rejects every bare request, including the
+ // metadata preflight. Prove possession first and ask store-status through
+ // the short-lived connect URL, just as the managed runtime does for sync.
+ // Without this exchange a healthy store looks unreachable, while a real
+ // no-schema refusal can be lost and enrollment incorrectly kept.
+ let preflightServerUrl = declared.serverUrl;
+ if (split.pop && deviceKeyPair) {
+ preflightServerUrl = await completePopExchange({
+ serverUrl: declared.serverUrl,
+ appId: declared.appId,
+ ticketId: split.pop.ticketId,
+ keyPair: deviceKeyPair,
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
+ }) ?? declared.serverUrl;
+ }
// The preflight decides whether enrollment is kept: a store that answers
// with a definite refusal rolls the declaration back before anything else
// (election, provision custody) observes it. An unreachable store is not a
@@ -457,7 +479,7 @@ export async function performTicketEnrollment(
// the warning recorded where status surfaces read it.
const status = await resolveStoreStatus({
connect: true,
- sink: { serverUrl: declared.serverUrl },
+ sink: { serverUrl: preflightServerUrl },
...(deps.preflight ? { preflight: deps.preflight } : {}),
...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),
});
diff --git a/package/runtime/session_test.ts b/package/runtime/session_test.ts
index e9ea65a..775cc6b 100644
--- a/package/runtime/session_test.ts
+++ b/package/runtime/session_test.ts
@@ -288,6 +288,112 @@ test(
}),
);
+test(
+ "a PoP-bound provision enrollment preflights through its authenticated connect URL",
+ withCleanSyncState(async () => {
+ const derivedSecret = "d".repeat(43);
+ const connectSecret = "c".repeat(43);
+ const requestedUrls: string[] = [];
+ const fetcher: typeof fetch = (input, init) => {
+ const url = String(input);
+ requestedUrls.push(url);
+ if (url.endsWith("/derive-sync-ticket")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (url.endsWith("/pop/challenge")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {
+ status: 200,
+ }),
+ );
+ }
+ if (url.endsWith("/pop/answer") && init?.method === "POST") {
+ return Promise.resolve(
+ new Response(JSON.stringify({ v: 1, connect: connectSecret }), { status: 200 }),
+ );
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ };
+ let preflightUrl = "";
+ await performTicketEnrollment(provisionTicket, {
+ fetcher,
+ keyStore: memoryDeviceKeyStore(),
+ preflight: (url) => {
+ preflightUrl = url;
+ return answering("deployed")();
+ },
+ elect: () => Promise.resolve(undefined as never),
+ });
+ assert(
+ preflightUrl ===
+ `http://192.168.1.10:4802/t/${derivedSecret}/c/${connectSecret}`,
+ `store-status must use the PoP-authenticated connect URL (received ${preflightUrl})`,
+ );
+ assert(
+ requestedUrls.some((url) => url.endsWith("/pop/challenge")) &&
+ requestedUrls.some((url) => url.endsWith("/pop/answer")),
+ "enrollment must complete the PoP exchange before preflight",
+ );
+ }),
+);
+
+test(
+ "a no_schema answer through a PoP connect URL still rolls enrollment back",
+ withCleanSyncState(async () => {
+ const fetcher: typeof fetch = (input) => {
+ const url = String(input);
+ if (url.endsWith("/derive-sync-ticket")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ v: 1, id: "derived-id", ticket: derivedTicket, pop: true }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (url.endsWith("/pop/challenge")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ id: "challenge-id", nonce: "nonce" }), {
+ status: 200,
+ }),
+ );
+ }
+ if (url.endsWith("/pop/answer")) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ v: 1, connect: "c".repeat(43) }), { status: 200 }),
+ );
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ };
+ let thrown: unknown;
+ try {
+ await performTicketEnrollment(provisionTicket, {
+ fetcher,
+ keyStore: memoryDeviceKeyStore(),
+ preflight: answering("no_schema"),
+ elect: () => Promise.reject(new Error("elect must not run")),
+ });
+ } catch (error) {
+ thrown = error;
+ }
+ assert(
+ isSyncEnrollmentError(thrown) && thrown.code === "no_schema",
+ `the authenticated no-schema answer must retain the enrollment safety gate (received ${
+ thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : String(thrown)
+ })`,
+ );
+ assert(readDeclaredSink() === null, "the PoP-bound sink must be rolled back");
+ assert(
+ !provisionCapabilityStatus().held,
+ "the provision capability must not be held after the refused enrollment",
+ );
+ }),
+);
+
test(
"electing under a foreign owner is refused; an unclaimed election records the owner",
withCleanSyncState(async () => {
diff --git a/package/runtime/store-status_test.ts b/package/runtime/store-status_test.ts
index e285235..bd70107 100644
--- a/package/runtime/store-status_test.ts
+++ b/package/runtime/store-status_test.ts
@@ -97,8 +97,12 @@ Deno.test("a hung or failing preflight degrades to store_unavailable", async ()
);
});
-Deno.test("only a /t/ path counts as a ticket-gated server URL", () => {
+Deno.test("only ticket and PoP connect paths count as ticket-gated server URLs", () => {
assert(isTicketServerUrl(ticketUrl), "a valid ticket URL must be recognized");
+ assert(
+ isTicketServerUrl(`${ticketUrl}/c/${"c".repeat(43)}`),
+ "a valid PoP connect URL must be recognized",
+ );
assert(
!isTicketServerUrl("https://sync.example.com"),
"a first-party server URL must not be probed",
@@ -107,6 +111,10 @@ Deno.test("only a /t/ path counts as a ticket-gated server URL", () => {
!isTicketServerUrl("https://node.example/t/short"),
"a short secret segment must not be treated as a ticket path",
);
+ assert(
+ !isTicketServerUrl(`${ticketUrl}/c/short`),
+ "a short connect token must not be treated as a ticket path",
+ );
assert(!isTicketServerUrl("not a url"), "a malformed URL must not be treated as a ticket path");
});
diff --git a/package/starter/src/islands/AccountGate.tsx.txt b/package/starter/src/islands/AccountGate.tsx.txt
index 6e8b66b..cf82c82 100644
--- a/package/starter/src/islands/AccountGate.tsx.txt
+++ b/package/starter/src/islands/AccountGate.tsx.txt
@@ -50,7 +50,7 @@ function describe(error: unknown): string {
if (isAuthError(error)) {
switch (error.code) {
case "cancelled":
- return "Passkey prompt dismissed — your recovery phrase was not shown.";
+ return "Passkey verification did not complete — your recovery phrase was not shown.";
case "unsupported":
return "This browser does not support passkeys.";
default:
diff --git a/package/testdata/starter.snapshot.json b/package/testdata/starter.snapshot.json
index 1fd1b5e..2c988e6 100644
--- a/package/testdata/starter.snapshot.json
+++ b/package/testdata/starter.snapshot.json
@@ -14,7 +14,7 @@
"README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",
"src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",
"src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",
- "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659",
+ "src/islands/AccountGate.tsx": "bce8ea5f64d4c9bb399b8665b0e14b3c20e6e3a9ab1663e011e811057b1f8a5e",
"src/islands/TaskList.tsx": "5b76707bf953d12a42d2be26c0a4f3eab77e972e228685a6cd945a65891e18f0",
"src/islands/use-tasks.ts": "277a54f6f77afebc303d869f0aac9db8423a2b5c707333046ce4d95993577a17",
"src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",
diff --git a/tools/demo_overlay_test.ts b/tools/demo_overlay_test.ts
index 59c2065..265134e 100644
--- a/tools/demo_overlay_test.ts
+++ b/tools/demo_overlay_test.ts
@@ -34,3 +34,18 @@ Deno.test("the demo landing page stamps the released version", async () => {
`which release produced the demo`,
);
});
+
+Deno.test("the demo uses the starter's durable notice surface", async () => {
+ const incidents = await Deno.readTextFile(
+ join(OVERLAY_ROOT, "src/islands/use-incidents.ts"),
+ );
+ const board = await Deno.readTextFile(
+ join(OVERLAY_ROOT, "src/islands/IncidentBoard.tsx"),
+ );
+ assert(incidents.includes("s.notice"), "incident effects must enqueue durable notices");
+ assert(
+ !incidents.includes("publishNotice") && !incidents.includes("useIncidentNotice"),
+ "the overlay must not restore the hand-rolled in-memory notice channel",
+ );
+ assert(board.includes("