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
- 134db87f5388fd50ea29243f406453cd42962460
- parent
- a007bfdabad7bcbafaaa08b00f1e5d0c85ccf45d
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 9 | +-- |
| M | apps/ | 52 | +++------------ |
| M | apps/ | 2 | +- |
| M | package/ | 36 | ++++++++++- |
| M | package/ | 21 | ++++++- |
| M | package/ | 7 | +- |
| M | package/ | 2 | +- |
| M | package/ | 3 | +- |
| M | package/ | 6 | ++ |
| M | package/ | 33 | ++++++---- |
| M | package/ | 10 | +++- |
| M | package/ | 20 | +++--- |
| M | package/ | 28 | +++++++- |
| M | package/ | 106 | ++++++++++++++++++++++++++++++ |
| M | package/ | 10 | +++- |
| M | package/ | 2 | +- |
| M | package/ | 2 | +- |
| M | tools/ | 15 | ++++ |
@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
22 import { settleUiMutation } from "@nzip/lofi";33 import {44 type BootProgress,5+ Notices,56 useBootProgress,67 usePendingWrites,78 useSyncStatus,@@ -10,7 +11,6 @@ import {
1011 type Incident,1112 type IncidentStatus,1213 type Severity,13- useIncidentNotice,1414 useIncidents,1515 } from "./use-incidents.ts";1616 @@ -51,7 +51,6 @@ function openedLabel(value: Incident["openedAt"]): string {
5151 */5252 export default function IncidentBoard() {5353 const { status, error, durability, incidents, failureKind, report, setStatus } = useIncidents();54- const notice = useIncidentNotice();5554 const pending = usePendingWrites();5655 const boot = useBootProgress();5756 const [title, setTitle] = useState("");@@ -107,11 +106,7 @@ export default function IncidentBoard() {
107106 {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync108107 </p>109108 )}110- {notice && (111- <p class="state" role="status" data-notice={notice.kind}>112- {notice.text}113- </p>114- )}109+ <Notices label="Incident notifications" />115110 <div class="columns">116111 {COLUMNS.map((column) => {117112 const rows = incidents.filter((incident) => incident.status === column.status);@@ -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);@@ -50,7 +50,7 @@ function describe(error: unknown): string {
5050 if (isAuthError(error)) {5151 switch (error.code) {5252 case "cancelled":53- return "Passkey prompt dismissed — your recovery phrase was not shown.";53+ return "Passkey verification did not complete — your recovery phrase was not shown.";5454 case "unsupported":5555 return "This browser does not support passkeys.";5656 default:@@ -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">@@ -1,4 +1,4 @@
1-import { describeSyncState } from "./DeviceStatus.tsx";1+import { describeCredentialOrigin, describeSyncState } from "./DeviceStatus.tsx";22 33 // The report's contract after the sync-state integrity pass: a reader must be44 // 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", () =>
4545 throw new Error(`owner mismatch was outranked: ${verdict}`);4646 }4747 });48+49+Deno.test("credential-origin status does not overstate API support", () => {50+ const local = describeCredentialOrigin({51+ status: "local-only",52+ rpId: "localhost",53+ action: "use the stable HTTPS origin",54+ });55+ if (!local.includes("local development only") || !local.includes("localhost")) {56+ throw new Error(`local credential origin was overstated: ${local}`);57+ }58+ const stable = describeCredentialOrigin({59+ status: "stable",60+ rpId: "demo.lofi.host",61+ action: "keep this hostname",62+ });63+ if (!stable.includes("stable") || !stable.includes("demo.lofi.host")) {64+ throw new Error(`stable credential origin lost its hostname: ${stable}`);65+ }66+});@@ -335,16 +335,19 @@ export type SyncTicket = {
335335 336336 const TICKET_PREFIX = "lofisync1.";337337 const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;338+const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;338339 339340 /**340- * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) and341+ * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) or342+ * its PoP-authenticated connect-token form (`/t/<secret>/c/<token>`) and341343 * therefore fronts a lofi-node gate, which is what exposes the metadata-only342344 * store-status endpoint. First-party Jazz servers and open-mode node URLs do343345 * not match.344346 */345347 export function isTicketServerUrl(serverUrl: string): boolean {346348 try {347- return TICKET_PATH.test(new URL(serverUrl).pathname);349+ const path = new URL(serverUrl).pathname;350+ return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);348351 } catch {349352 return false;350353 }@@ -208,7 +208,7 @@ export {
208208 type SinkRestoreOutcome,209209 type SyncTicket,210210 } from "./data-sink.ts";211-export { RecoveryError } from "./recovery.ts";211+export { RecoveryError, type RecoveryErrorCode } from "./recovery.ts";212212 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";213213 export {214214 type RowOf,@@ -21,7 +21,8 @@ export type RecoverablePasskeyErrorCode =
2121 | "restore-failed";2222 2323 const messages: Record<RecoverablePasskeyErrorCode, string> = {24- cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",24+ cancelled:25+ "A passkey was not created or opened. Nothing on this device was replaced; try again or use the recovery phrase.",2526 unsupported:2627 "This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",2728 "credential-missing":@@ -25,6 +25,12 @@ Deno.test("passkey backup errors become actionable non-secret recovery errors",
2525 !mapped.message.includes("vendor detail"),2626 "vendor detail leaked into public error text",2727 );28+ if (code === "cancelled") {29+ assert(30+ mapped.message.includes("not created or opened") && !mapped.message.includes("cancelled"),31+ "NotAllowedError guidance must not assume the person dismissed the prompt",32+ );33+ }2834 }2935 });3036 @@ -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);@@ -49,12 +49,20 @@ test("fromRecoveryPhrase tolerates messy whitespace and casing", () => {
4949 5050 test("fromRecoveryPhrase rejects a wrong word count with invalid-length", () => {5151 let code: string | undefined;52+ let message = "";5253 try {5354 fromRecoveryPhrase("one two three");5455 } catch (error) {55- if (error instanceof RecoveryError) code = error.code;56+ if (error instanceof RecoveryError) {57+ code = error.code;58+ message = error.message;59+ }5660 }5761 assert(code === "invalid-length", "too few words must raise invalid-length");62+ assert(63+ message.includes(`${RECOVERY_PHRASE_WORDS} words`) && !message.includes("invalid-length"),64+ "the invalid-length error must give person-readable word-count guidance",65+ );5866 });5967 6068 test("fromRecoveryPhrase rejects an empty phrase with invalid-length", () => {@@ -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,@@ -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 });@@ -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 () => {@@ -97,8 +97,12 @@ Deno.test("a hung or failing preflight degrades to store_unavailable", async ()
9797 );9898 });9999 100-Deno.test("only a /t/<secret> path counts as a ticket-gated server URL", () => {100+Deno.test("only ticket and PoP connect paths count as ticket-gated server URLs", () => {101101 assert(isTicketServerUrl(ticketUrl), "a valid ticket URL must be recognized");102+ assert(103+ isTicketServerUrl(`${ticketUrl}/c/${"c".repeat(43)}`),104+ "a valid PoP connect URL must be recognized",105+ );102106 assert(103107 !isTicketServerUrl("https://sync.example.com"),104108 "a first-party server URL must not be probed",@@ -107,6 +111,10 @@ Deno.test("only a /t/<secret> path counts as a ticket-gated server URL", () => {
107111 !isTicketServerUrl("https://node.example/t/short"),108112 "a short secret segment must not be treated as a ticket path",109113 );114+ assert(115+ !isTicketServerUrl(`${ticketUrl}/c/short`),116+ "a short connect token must not be treated as a ticket path",117+ );110118 assert(!isTicketServerUrl("not a url"), "a malformed URL must not be treated as a ticket path");111119 });112120 @@ -50,7 +50,7 @@ function describe(error: unknown): string {
5050 if (isAuthError(error)) {5151 switch (error.code) {5252 case "cancelled":53- return "Passkey prompt dismissed — your recovery phrase was not shown.";53+ return "Passkey verification did not complete — your recovery phrase was not shown.";5454 case "unsupported":5555 return "This browser does not support passkeys.";5656 default:@@ -14,7 +14,7 @@
1414 "README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",1515 "src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",17- "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659",17+ "src/islands/AccountGate.tsx": "bce8ea5f64d4c9bb399b8665b0e14b3c20e6e3a9ab1663e011e811057b1f8a5e",1818 "src/islands/TaskList.tsx": "5b76707bf953d12a42d2be26c0a4f3eab77e972e228685a6cd945a65891e18f0",1919 "src/islands/use-tasks.ts": "277a54f6f77afebc303d869f0aac9db8423a2b5c707333046ce4d95993577a17",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",@@ -34,3 +34,18 @@ Deno.test("the demo landing page stamps the released version", async () => {
3434 `which release produced the demo`,3535 );3636 });37+38+Deno.test("the demo uses the starter's durable notice surface", async () => {39+ const incidents = await Deno.readTextFile(40+ join(OVERLAY_ROOT, "src/islands/use-incidents.ts"),41+ );42+ const board = await Deno.readTextFile(43+ join(OVERLAY_ROOT, "src/islands/IncidentBoard.tsx"),44+ );45+ assert(incidents.includes("s.notice<Incident>"), "incident effects must enqueue durable notices");46+ assert(47+ !incidents.includes("publishNotice") && !incidents.includes("useIncidentNotice"),48+ "the overlay must not restore the hand-rolled in-memory notice channel",49+ );50+ assert(board.includes("<Notices"), "the incident board must render the durable notice queue");51+});