diff --git a/src/data/user/quarantine.ts b/src/data/user/quarantine.ts new file mode 100644 index 0000000..a940366 --- /dev/null +++ b/src/data/user/quarantine.ts @@ -0,0 +1,132 @@ +/** + * Persistent quarantine store — rejected (edition, packageVersion) pairs so a + * known-bad version is never re-fetched until latest.json advances past it. + * Lives in `lumen-user` (diag concern, contract §9) — survives dataset GC. + * Written as lumen-user v2 via the openDB versionchange seam. + * Trace: IMPLEMENTATION-CONTRACT.md §11 (quarantine no-loop), Stage 7, + * ARCHITECTURE-DESIGN.md:675, SPIKE-02 F-5 + */ +import { withTx, idbGet, idbPut, idbGetAll, idbDelete } from "../../platform/idb/wrapper.js"; +import { USER_QUARANTINE } from "../../platform/idb/names.js"; + +/** + * Local structural twin of sync/verifier QuarantineRecord (B-boundary: data + * imports platform only). Field-compatible, so sync can pass its records + * straight through to addQuarantined / quarantineSink. + */ +export interface QuarantineRecordLike { + readonly edition: string | null; + readonly packageVersion: number | null; + readonly code: string; + readonly reason: string; + readonly at: number; +} + +export const USER_DB_VERSION = 2; + +export function quarantineKey(edition: string, packageVersion: number): string { + return `${edition}/${String(packageVersion)}`; +} + +/** Record shape persisted under `edition/packageVersion`. */ +export interface QuarantinedVersion { + readonly edition: string; + readonly packageVersion: number; + readonly code: string; + readonly reason: string; + readonly at: number; +} + +export async function addQuarantined(db: IDBDatabase, record: QuarantineRecordLike): Promise { + if (record.edition === null || record.packageVersion === null) { + // Cannot key an unidentified rejection — diag entry still gets written by + // the caller; quarantine (which prevents refetch) needs both coordinates. + return; + } + await idbPut( + db, + USER_QUARANTINE, + { + edition: record.edition, + packageVersion: record.packageVersion, + code: record.code, + reason: record.reason, + at: record.at, + } satisfies QuarantinedVersion, + quarantineKey(record.edition, record.packageVersion), + ); +} + +export async function isQuarantined( + db: IDBDatabase, + edition: string, + packageVersion: number, +): Promise { + const v = await idbGet( + db, + USER_QUARANTINE, + quarantineKey(edition, packageVersion), + ); + return v !== undefined; +} + +/** All quarantined versions for an edition (ascending by packageVersion). */ +export async function listQuarantined( + db: IDBDatabase, + edition: string, +): Promise { + const all = await idbGetAll(db, USER_QUARANTINE); + return all + .filter((q) => q.edition === edition) + .sort((a, b) => a.packageVersion - b.packageVersion); +} + +/** Clear one entry (e.g. organizer republished content at that version via rollback runbook). */ +export async function clearQuarantined( + db: IDBDatabase, + edition: string, + packageVersion: number, +): Promise { + await idbDelete(db, USER_QUARANTINE, quarantineKey(edition, packageVersion)); +} + +/** Clear every quarantined version for an edition except strictly-newer than a version. */ +export async function pruneQuarantinedUpTo( + db: IDBDatabase, + edition: string, + throughInclusive: number, +): Promise { + const doomed = (await listQuarantined(db, edition)).filter( + (q) => q.packageVersion <= throughInclusive, + ); + if (doomed.length === 0) return 0; + await withTx(db, USER_QUARANTINE, "readwrite", async (tx) => { + const os = tx.objectStore(USER_QUARANTINE); + for (const q of doomed) { + const req = os.delete(quarantineKey(q.edition, q.packageVersion)); + await new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(); + }; + req.onerror = () => { + reject(req.error ?? new Error("IDB error")); + }; + }); + } + }); + return doomed.length; +} + +/** + * QuarantineSink backed by the store — wire into VerifyDependencies.quarantine + * so every verifier rejection lands in persistent quarantine automatically. + */ +export interface QuarantineSinkLike { + readonly add: (record: QuarantineRecordLike) => Promise | void; +} + +export function quarantineSink(db: IDBDatabase): QuarantineSinkLike { + return { + add: (record) => addQuarantined(db, record), + }; +} diff --git a/src/data/user/store.ts b/src/data/user/store.ts index 5944cb7..240b1e4 100644 --- a/src/data/user/store.ts +++ b/src/data/user/store.ts @@ -13,12 +13,12 @@ import { idbDelete, idbCount, } from "../../platform/idb/wrapper.js"; -import { DB, USER_FAVS, USER_PREFS, USER_DIAG } from "../../platform/idb/names.js"; +import { DB, USER_FAVS, USER_PREFS, USER_DIAG, USER_QUARANTINE } from "../../platform/idb/names.js"; import type { FavoriteEntry, UserPrefs, DiagEntry } from "./types.js"; -const USER_VERSION = 1; +const USER_VERSION = 2; -function upgradeUser(db: IDBDatabase): void { +function upgradeUser(db: IDBDatabase, oldVersion: number): void { if (!db.objectStoreNames.contains(USER_FAVS)) { db.createObjectStore(USER_FAVS); } @@ -28,11 +28,16 @@ function upgradeUser(db: IDBDatabase): void { if (!db.objectStoreNames.contains(USER_DIAG)) { db.createObjectStore(USER_DIAG); } + // v1 -> v2: persistent quarantine store (Stage 7 §11 no-loop). Additive; + // favorites/prefs/diag untouched (B-6). + if (oldVersion < 2 && !db.objectStoreNames.contains(USER_QUARANTINE)) { + db.createObjectStore(USER_QUARANTINE); + } } export function openUserDB(): Promise { - return openDB(DB.USER, USER_VERSION, (db) => { - upgradeUser(db); + return openDB(DB.USER, USER_VERSION, (db, oldVersion) => { + upgradeUser(db, oldVersion); }); } diff --git a/src/platform/idb/names.ts b/src/platform/idb/names.ts index 1b7d0eb..fddc1ad 100644 --- a/src/platform/idb/names.ts +++ b/src/platform/idb/names.ts @@ -26,5 +26,6 @@ export const SLOT_STAGING = "staging" as const; export const USER_FAVS = "favorites" as const; export const USER_PREFS = "prefs" as const; export const USER_DIAG = "diag" as const; +export const USER_QUARANTINE = "quarantine" as const; export type DbName = (typeof DB)[keyof typeof DB]; diff --git a/src/sync/pull.ts b/src/sync/pull.ts index 1906cf2..167d721 100644 --- a/src/sync/pull.ts +++ b/src/sync/pull.ts @@ -8,6 +8,14 @@ export interface CandidatePackage { readonly result: VerifyResult; } +/** Skip decision when the pointer targets a version already quarantined. */ +export interface QuarantineSkipped { + readonly skipped: "quarantined"; + readonly pointer: LatestPointer; +} + +export type PullOutcome = CandidatePackage | QuarantineSkipped | null; + function siblingUrl(manifestUrl: string, name: string): string { if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString(); return new URL(name, `https://transport.invalid${manifestUrl}`).pathname; @@ -16,16 +24,28 @@ function siblingUrl(manifestUrl: string, name: string): string { /** * Pulls through the verification boundary and stops at a verified package. * No staging, activation, retry loop, or user-data operation belongs here. + * + * Quarantine no-loop (§11): when a `isQuarantined` predicate is supplied and + * the pointer targets a rejected version, the pull stops BEFORE fetching the + * manifest or any file — a known-bad version is never re-fetched until + * latest.json advances past it. */ export async function pullCandidate( transport: Transport, edition: string, deps: VerifyDependencies, - options: { readonly signal?: AbortSignal; readonly timeoutMs?: number } = {}, -): Promise { + options: { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly isQuarantined?: (packageVersion: number) => Promise; + } = {}, +): Promise { if (!transport.isAvailable()) return null; const pointer = await transport.fetchPointer(edition, options); if (!pointer) return null; + if (options.isQuarantined && (await options.isQuarantined(pointer.packageVersion))) { + return { skipped: "quarantined", pointer }; + } const manifestUrl = pointer.manifestUrl; const manifestBytes = await transport.fetchBytes(manifestUrl, options); const signatureBytes = await transport.fetchBytes( diff --git a/tests/unit/transport.test.ts b/tests/unit/transport.test.ts index b4b4189..cd237bc 100644 --- a/tests/unit/transport.test.ts +++ b/tests/unit/transport.test.ts @@ -154,7 +154,8 @@ describe("Stage 9 pull and verifier boundary", () => { supportedSchemaRange: [1], }, ); - expect(result?.result.ok).toBe(true); + if (!result || "skipped" in result) throw new Error("expected candidate"); + expect(result.result.ok).toBe(true); expect(calls[0]).toMatch(/latest\.json$/); expect(calls[1]).toMatch(/manifest\.json$/); expect(calls[2]).toMatch(/signature\.json$/); @@ -187,7 +188,8 @@ describe("Stage 9 pull and verifier boundary", () => { supportedSchemaRange: [1], }, ); - expect(result?.result).toMatchObject({ ok: false, code: "signature_mismatch" }); + if (!result || "skipped" in result) throw new Error("expected candidate"); + expect(result.result).toMatchObject({ ok: false, code: "signature_mismatch" }); expect(calls).toHaveLength(3); expect(calls.some((url) => /emergency|schedule|map|info|assets\.json/.test(url))).toBe(false); });