From dc009b34e232db76a664a2a4bb0286b92f497255 Mon Sep 17 00:00:00 2001 From: Phillip Jones Date: Mon, 7 Sep 2026 16:21:51 -0700 Subject: [PATCH 01/15] Fix Outputs sidebar not stretching to full height (#454) --- packages/workshop-frontend/src/GadgetEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workshop-frontend/src/GadgetEditor.tsx b/packages/workshop-frontend/src/GadgetEditor.tsx index aeae2d7928..a5fcc7eaa5 100644 --- a/packages/workshop-frontend/src/GadgetEditor.tsx +++ b/packages/workshop-frontend/src/GadgetEditor.tsx @@ -1921,7 +1921,7 @@ export default function GadgetEditor() { {showOutputRail && ( -
+
Date: Tue, 8 Sep 2026 11:20:43 -0500 Subject: [PATCH 02/15] Support multi-tab Google Docs (#450) The gatekeeper asked Google for all tab content and then rejected any document with multiple or nested tabs, blocking both directly bound Docs and read-only Docs opened through Drive. Google's model is a recursive tab tree whose bodies have independent index spaces, so reads must traverse Document.tabs and every write Location/Range must carry the immutable tabId. The capability stays whole-document; tabId is only an operation target. No tab create, rename, move, duplicate or delete API is added. - docs-api.ts flattens the provider tree into a preorder adjacency list, deriving ancestry, sibling index and nesting level from the tree actually returned rather than trusting tabProperties, and requiring a globally unique non-empty tabId and a non-empty body. - markdown-converter.ts converts one tab at a time (DocTabSnapshot), since character indices restart per tab, and stamps the selected tabId on every insert location and delete/style/bullet/link range. - google.ts caches a per-tab snapshot, resolves a selector fail-closed (omission is legal only when the flattened list holds one tab, and an unknown ID never falls back to the first), replays the pending queue in global order but per tab, and scopes committed-marker suppression to the tab that owns the marker. - getContent/replaceText/appendText take an optional tabId; listTabs() is new on the read session shared by Drive. Agent guidance requires listing tabs before reading one. Both sessions reuse one revision for ten seconds and then recheck it, so concurrent reads share a fetch without pinning a long-lived Drive session to the revision it first saw. Google populates revisionId only for callers with edit access, so it is typed as optional and a cache is never confirmed by an absent one: a view-only Doc is refetched rather than spending a request on an answer that could not confirm it. A modification time needs the opposite default, since a document offering no change token must not look edited by every read, so the bound session reports Drive's modifiedTime for one. Only a refused grant falls back to the first observation, for an account whose grant predates the picker's metadata scope; a quota 403 (Drive rate-limits with that status too), an outage or a malformed body are raised instead, because dating a document from one would report it unchanged for as long as Drive stayed unhealthy and the stored observation would outlive the incident. Approval and observation text names a tab by ID as well as title: titles are user-authored, need not be unique, and may be empty, while the write targets the ID. An approved edit that cannot be applied now reports why, rather than being removed while the overseer records it as applied. Throwing is the documented applyAction contract: the record stays pending and the user is offered a retry or a discard. The edit is invalidated rather than removed, so later edits stop queuing behind it and a repeated approval repeats the reason instead of decaying into an unknown-action error; rejecting clears it. A pending edit stored before this change names no tab. The old code refused to read a document with more than one tab, so such an edit was approved against a document that had exactly one: it is retargeted while the document still holds a single tab, and invalidated only once tabs added since leave its target unknowable. Both the replay filter and the apply-time lookup search every tab for such an edit's marker, so a write that committed before the upgrade and lost its response is reconciled instead of being dropped with its named range orphaned in the document. A failed read or edit authorizes a generic observation before its error is thrown: the error distinguishes a live tab from a missing one, and for replaceText, present text from absent. --- .../__tests__/doc-fixture.ts | 19 +- .../__tests__/markdown-converter.test.ts | 79 ++- .../__tests__/native-api.test.ts | 135 ++++- .../gatekeeper-google/__tests__/types.test.ts | 74 ++- .../gatekeeper-google/__tests__/worker.ts | 93 ++- .../workerd/google-doc-actions.test.ts | 501 ++++++++++++++-- .../__tests__/workerd/native-sessions.test.ts | 199 ++++++- packages/gatekeeper-google/src/docs-api.ts | 150 +++-- .../src/docs-read-types.d.ts | 42 +- .../gatekeeper-google/src/docs-types.d.ts | 25 +- .../gatekeeper-google/src/drive-session.ts | 12 +- packages/gatekeeper-google/src/google.ts | 560 +++++++++++++----- .../src/markdown-converter.ts | 88 +-- packages/gatekeeper-google/src/type-bundle.ts | 2 +- 14 files changed, 1594 insertions(+), 385 deletions(-) diff --git a/packages/gatekeeper-google/__tests__/doc-fixture.ts b/packages/gatekeeper-google/__tests__/doc-fixture.ts index 60d4e49bd3..94e4befd3d 100644 --- a/packages/gatekeeper-google/__tests__/doc-fixture.ts +++ b/packages/gatekeeper-google/__tests__/doc-fixture.ts @@ -1,5 +1,5 @@ import type { - GoogleDocsDocument, ParagraphElement, StructuralElement, TextStyle, + GoogleDocsTab, ParagraphElement, StructuralElement, TextStyle, } from "../src/docs-api"; /** A styled span of text within a paragraph. */ @@ -12,15 +12,15 @@ type ParagraphSpec = { }; /** - * Builds a `GoogleDocsDocument` with the index bookkeeping the real API applies: a section break - * occupies index 0, and every paragraph's runs are laid out contiguously from index 1. + * Builds a normalized `GoogleDocsTab` with the index bookkeeping the real API applies: a section + * break occupies index 0, and every paragraph's runs are laid out contiguously from index 1. * * Every paragraph's last run must end in "\n", as Google's own responses do. */ -export function buildDoc( +export function buildTab( paragraphs: ParagraphSpec[], - lists: GoogleDocsDocument["lists"] = {}, -): GoogleDocsDocument { + lists: GoogleDocsTab["lists"] = {}, +): GoogleDocsTab { let index = 1; let content: StructuralElement[] = [{ startIndex: 0, endIndex: 1, sectionBreak: {} }]; @@ -48,9 +48,10 @@ export function buildDoc( } return { - documentId: "doc-1", + tabId: "tab-1", title: "Fixture", - revisionId: "rev-1", + index: 0, + nestingLevel: 0, body: { content }, lists, namedRanges: {}, @@ -58,6 +59,6 @@ export function buildDoc( } /** A single-level bullet list definition, for paragraphs carrying a matching `bullet`. */ -export const BULLET_LIST: GoogleDocsDocument["lists"] = { +export const BULLET_LIST: GoogleDocsTab["lists"] = { L1: { listProperties: { nestingLevels: [{ glyphSymbol: "\u25cf" }] } }, }; diff --git a/packages/gatekeeper-google/__tests__/markdown-converter.test.ts b/packages/gatekeeper-google/__tests__/markdown-converter.test.ts index 648bb3a8ac..409ddbb953 100644 --- a/packages/gatekeeper-google/__tests__/markdown-converter.test.ts +++ b/packages/gatekeeper-google/__tests__/markdown-converter.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "vitest"; -import { computeReplaceOperations, docToMarkdown } from "../src/markdown-converter"; +import { + computeReplaceOperations, docTabToMarkdown, markdownToDocRequests, +} from "../src/markdown-converter"; import type { Segment } from "../src/markdown-converter"; -import { BULLET_LIST, buildDoc } from "./doc-fixture"; +import { BULLET_LIST, buildTab } from "./doc-fixture"; /** A segment with a document counterpart, as opposed to a Markdown-syntax-only one. */ type ContentSegment = Exclude; const isContent = (seg: Segment): seg is ContentSegment => !("syntaxOnly" in seg); +const TAB_ID = "tab-1"; + /** * The document text as Google stores it, aligned so that a string index equals a doc index: index * 0 is the section break, and run text begins at 1. @@ -16,9 +20,23 @@ function docText(runs: string[]): string { return "\u0000" + runs.join(""); } -describe("docToMarkdown", () => { +/** Every `Location`/`Range` object nested anywhere inside a batchUpdate request. */ +function coordinates(requests: unknown[]): Record[] { + let found: Record[] = []; + let visit = (value: unknown) => { + if (!value || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + if (key === "location" || key === "range") found.push(nested as Record); + visit(nested); + } + }; + visit(requests); + return found; +} + +describe("docTabToMarkdown", () => { it("renders headings, inline styles, links and bullets", () => { - let snapshot = docToMarkdown(buildDoc([ + let snapshot = docTabToMarkdown(buildTab([ { runs: ["Title\n"], namedStyleType: "HEADING_1" }, { runs: ["Sub\n"], namedStyleType: "HEADING_2" }, { runs: [ @@ -38,10 +56,18 @@ describe("docToMarkdown", () => { "# Title\n\n## Sub\n\nHello **bold** and *it* and [link](https://e.com).\n\n- one\n- two\n"); }); - it("carries the title, revision and body end index through", () => { - let snapshot = docToMarkdown(buildDoc([{ runs: ["abc\n"] }])); - expect(snapshot.title).toBe("Fixture"); - expect(snapshot.revisionId).toBe("rev-1"); + it("carries the tab's identity, position and body end index through", () => { + let snapshot = docTabToMarkdown({ + ...buildTab([{ runs: ["abc\n"] }]), + tabId: "metrics", + title: "Metrics", + parentTabId: "details", + index: 1, + nestingLevel: 2, + }); + expect(snapshot).toMatchObject({ + tabId: "metrics", title: "Metrics", parentTabId: "details", index: 1, nestingLevel: 2, + }); // Section break (1) + "abc\n" (4). expect(snapshot.bodyEndIndex).toBe(5); }); @@ -50,7 +76,7 @@ describe("docToMarkdown", () => { // These are what keeps an edit from landing on the wrong characters. A content segment claims a // 1:1 mapping between Markdown and document indices, and computeReplaceOperations trusts it. describe("source map invariants", () => { - let snapshot = docToMarkdown(buildDoc([ + let snapshot = docTabToMarkdown(buildTab([ { runs: ["Title\n"], namedStyleType: "HEADING_1" }, { runs: [ "Hello ", @@ -104,8 +130,25 @@ describe("source map invariants", () => { }); }); +// Tab bodies have independent index spaces, so a coordinate without the selected tab's ID would +// land in whichever tab Google picks by default. +describe("selected-tab write coordinates", () => { + it("stamps the tab ID on every inserted location and styled range", () => { + let requests = markdownToDocRequests( + "# Head\n\n- one\n\n**bold** and [link](https://e.com)\n", 7, "metrics"); + + expect(requests.map(request => Object.keys(request)[0])).toEqual([ + "insertText", "updateParagraphStyle", "createParagraphBullets", "updateTextStyle", + "updateTextStyle", + ]); + let found = coordinates(requests); + expect(found).toHaveLength(requests.length); + for (const coordinate of found) expect(coordinate.tabId).toBe("metrics"); + }); +}); + describe("computeReplaceOperations", () => { - let snapshot = docToMarkdown(buildDoc([ + let snapshot = docTabToMarkdown(buildTab([ { runs: ["Title\n"], namedStyleType: "HEADING_1" }, { runs: ["Hello ", { text: "bold", style: { bold: true } }, " world.\n"] }, ])); @@ -114,7 +157,7 @@ describe("computeReplaceOperations", () => { let start = md.indexOf(oldText); expect(start).toBeGreaterThanOrEqual(0); return computeReplaceOperations( - snapshot.sourceMap, md, start, start + oldText.length, newText); + snapshot.sourceMap, md, start, start + oldText.length, newText, TAB_ID); }; it("renders the fixture as expected", () => { @@ -130,8 +173,8 @@ describe("computeReplaceOperations", () => { trimmedOld: "world", trimmedNew: "there", requests: [ - { deleteContentRange: { range: { startIndex: 18, endIndex: 23 } } }, - { insertText: { location: { index: 18 }, text: "there" } }, + { deleteContentRange: { range: { startIndex: 18, endIndex: 23, tabId: TAB_ID } } }, + { insertText: { location: { index: 18, tabId: TAB_ID }, text: "there" } }, ], }); }); @@ -140,7 +183,7 @@ describe("computeReplaceOperations", () => { expect(replace("world", "worlds")).toEqual({ trimmedOld: "", trimmedNew: "s", - requests: [{ insertText: { location: { index: 23 }, text: "s" } }], + requests: [{ insertText: { location: { index: 23, tabId: TAB_ID }, text: "s" } }], }); }); @@ -148,7 +191,7 @@ describe("computeReplaceOperations", () => { expect(replace("world", "")).toEqual({ trimmedOld: "world", trimmedNew: "", - requests: [{ deleteContentRange: { range: { startIndex: 18, endIndex: 23 } } }], + requests: [{ deleteContentRange: { range: { startIndex: 18, endIndex: 23, tabId: TAB_ID } } }], }); }); @@ -165,7 +208,7 @@ describe("computeReplaceOperations", () => { let inserted = result.requests[1].insertText.text; // The delete covers "Hello bold world.\n" (doc 7..25), so the insert must restore all of it. expect({ deleted, inserted }).toEqual({ - deleted: { startIndex: 7, endIndex: 25 }, + deleted: { startIndex: 7, endIndex: 25, tabId: TAB_ID }, inserted: "Hello plain world.\n", }); }); @@ -173,8 +216,8 @@ describe("computeReplaceOperations", () => { it("currently truncates the paragraph in that case", () => { let result = replace("**bold**", "plain"); expect(result.requests).toEqual([ - { deleteContentRange: { range: { startIndex: 7, endIndex: 25 } } }, - { insertText: { location: { index: 7 }, text: "plain" } }, + { deleteContentRange: { range: { startIndex: 7, endIndex: 25, tabId: TAB_ID } } }, + { insertText: { location: { index: 7, tabId: TAB_ID }, text: "plain" } }, ]); }); }); diff --git a/packages/gatekeeper-google/__tests__/native-api.test.ts b/packages/gatekeeper-google/__tests__/native-api.test.ts index 1dcf850d9c..fb448d0970 100644 --- a/packages/gatekeeper-google/__tests__/native-api.test.ts +++ b/packages/gatekeeper-google/__tests__/native-api.test.ts @@ -1,27 +1,41 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { GoogleDocsApi } from "../src/docs-api"; +import { GoogleDocsApi, type GoogleDocsTab } from "../src/docs-api"; import { GoogleSheetsApi } from "../src/sheets-api"; import { readGoogleJson } from "../src/google-response"; const token = async () => "access-token"; -function docBody() { +type RawTab = { + tabProperties: { tabId: string; title: string }; + documentTab?: Pick & Partial>; + childTabs?: RawTab[]; +}; + +/** The section break every real document body opens with. */ +const EMPTY_BODY = { content: [{ startIndex: 0, endIndex: 1, sectionBreak: {} }] }; + +/** One provider tab, with the body and collections a fresh tab really returns. */ +function rawTab(tabId: string, title: string, childTabs?: RawTab[]): RawTab { return { - documentId: "doc-1", - title: "Quarterly plan", - revisionId: "revision-1", - body: { content: [] }, - lists: {}, - namedRanges: {}, + tabProperties: { tabId, title }, + documentTab: { body: EMPTY_BODY, lists: {}, namedRanges: {} }, + ...childTabs ? { childTabs } : {}, }; } -function docResponse(tabCount = 1) { - const { body, lists, namedRanges, ...document } = docBody(); - const documentTab = { body, lists, namedRanges }; +function docResponse(tabs: RawTab[] = [rawTab("tab-1", "Main")]) { + return { documentId: "doc-1", title: "Quarterly plan", revisionId: "revision-1", tabs }; +} + +/** The normalized counterpart of `rawTab`, with ancestry the caller states explicitly. */ +function normalizedTab( + tabId: string, + title: string, + position: Partial> = {}, +): GoogleDocsTab { return { - ...document, - tabs: Array.from({ length: tabCount }, () => ({ documentTab, childTabs: [] })), + tabId, title, index: 0, nestingLevel: 0, ...position, + body: EMPTY_BODY, lists: {}, namedRanges: {}, }; } @@ -62,14 +76,19 @@ afterEach(() => { }); describe("native Google content API safety", () => { - it("requests tabs and normalizes a single-tab document", async () => { + it("requests tab content and normalizes a single-tab document", async () => { let requestedUrl: string | undefined; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { requestedUrl = input instanceof Request ? input.url : input.toString(); return Response.json(docResponse()); })); - await expect(new GoogleDocsApi(token).getDocument("doc-1")).resolves.toEqual(docBody()); + await expect(new GoogleDocsApi(token).getDocument("doc-1")).resolves.toEqual({ + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + tabs: [normalizedTab("tab-1", "Main")], + }); expect(requestedUrl).toBe( "https://docs.googleapis.com/v1/documents/doc-1?includeTabsContent=true", ); @@ -112,11 +131,82 @@ describe("native Google content API safety", () => { .rejects.toThrow("Google Docs returned a different document"); }); - it("rejects multi-tab documents instead of silently reading the first tab", async () => { - vi.stubGlobal("fetch", vi.fn(async () => Response.json(docResponse(2)))); + it("flattens the tab tree in preorder with derived ancestry", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json(docResponse([ + rawTab("overview", "Overview", [rawTab("details", "Details", [ + rawTab("metrics", "Metrics"), + ])]), + rawTab("appendix", "Appendix"), + ])))); + + await expect(new GoogleDocsApi(token).getDocument("doc-1")).resolves.toMatchObject({ + tabs: [ + normalizedTab("overview", "Overview"), + normalizedTab("details", "Details", { parentTabId: "overview", nestingLevel: 1 }), + normalizedTab("metrics", "Metrics", { parentTabId: "details", nestingLevel: 2 }), + normalizedTab("appendix", "Appendix", { index: 1 }), + ], + }); + }); - await expect(new GoogleDocsApi(token).getDocument("doc-1")) - .rejects.toThrow("Multi-tab Google Docs are not supported"); + it("keeps each tab's body, lists and named ranges to itself", async () => { + const documentTab = { + body: { content: [{ startIndex: 0, endIndex: 1, sectionBreak: {} }] }, + lists: { L1: { listProperties: { nestingLevels: [{ glyphSymbol: "\u25cf" }] } } }, + namedRanges: { mark: { namedRanges: [{ namedRangeId: "range-1", name: "mark" }] } }, + }; + vi.stubGlobal("fetch", vi.fn(async () => Response.json(docResponse([ + { ...rawTab("overview", "Overview"), documentTab }, + rawTab("appendix", "Appendix"), + ])))); + + const { tabs } = await new GoogleDocsApi(token).getDocument("doc-1"); + + expect(tabs[0]).toMatchObject(documentTab); + expect(tabs[1]).toMatchObject({ body: EMPTY_BODY, lists: {}, namedRanges: {} }); + }); + + it("defaults absent tab collections to empty", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json(docResponse([{ + tabProperties: { tabId: "solo", title: "Solo" }, + documentTab: { body: EMPTY_BODY }, + }])))); + + await expect(new GoogleDocsApi(token).getDocument("doc-1")).resolves.toMatchObject({ + tabs: [normalizedTab("solo", "Solo")], + }); + }); + + it.each([ + ["no tabs", [], "Google Docs returned no document tab"], + ["a duplicate tab ID", [rawTab("dup", "One"), rawTab("dup", "Two")], + "Google Docs returned a duplicate tab ID"], + ["an empty tab ID", [rawTab("", "Nameless")], "Google Docs returned an invalid tab"], + ["a missing title", [{ tabProperties: { tabId: "solo" } }], + "Google Docs returned an invalid tab"], + ["a tab without content", [{ tabProperties: { tabId: "solo", title: "Solo" } }], + "Google Docs returned an invalid tab"], + ["a malformed body", [{ + tabProperties: { tabId: "solo", title: "Solo" }, + documentTab: { body: { content: "text" } }, + }], "Google Docs returned an invalid tab"], + ["malformed child tabs", [{ + ...rawTab("solo", "Solo"), childTabs: {}, + }], "Google Docs returned an invalid tab"], + ["an empty body", [{ + tabProperties: { tabId: "solo", title: "Solo" }, + documentTab: { body: { content: [] } }, + }], "Google Docs returned an invalid tab"], + ["malformed named ranges", [{ + tabProperties: { tabId: "solo", title: "Solo" }, + documentTab: { body: EMPTY_BODY, namedRanges: [] }, + }], "Google Docs returned an invalid tab"], + ] as const)("rejects a response with %s", async (_case, tabs, message) => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json( + docResponse(tabs as unknown as RawTab[]), + ))); + + await expect(new GoogleDocsApi(token).getDocument("doc-1")).rejects.toThrow(message); }); it("revision-locks marked writes and returns the created range ID", async () => { @@ -130,10 +220,11 @@ describe("native Google content API safety", () => { writeControl: { requiredRevisionId: "revision-2" }, }); })); - const request = { insertText: { text: "hello", location: { index: 1 } } }; + const request = { insertText: { text: "hello", location: { index: 1, tabId: "metrics" } } }; const result = await new GoogleDocsApi(token).batchUpdate( - "doc-1", [request], "revision-1", { name: "gadgets-write-1", rangeStart: 1 }, + "doc-1", [request], "revision-1", + { name: "gadgets-write-1", rangeStart: 1, tabId: "metrics" }, ); expect(result).toEqual({ revisionId: "revision-2", writeMarkerId: "range-1" }); @@ -142,7 +233,7 @@ describe("native Google content API safety", () => { { createNamedRange: { name: "gadgets-write-1", - range: { startIndex: 1, endIndex: 2 }, + range: { startIndex: 1, endIndex: 2, tabId: "metrics" }, }, }, request, diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts index 7d8f312f0e..9bcd7917e6 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -44,34 +44,82 @@ function compileAgentTypes(sourceText: string): string[] { ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); } +/** Guidance the agent must still be reading when it picks a tab to act on. */ +const TAB_GUIDANCE = [ + "Call `listTabs()` before `getContent()`", + "reads exactly one tab and never combines tabs", + "pass an ID returned by `listTabs()`", + "only when `listTabs()` returns exactly one tab", +]; + +/** + * Fails to compile unless `GoogleDocTab` has exactly the flattened adjacency-list members. + * + * Mutual assignability alone misses an added or removed *optional* property, since excess + * properties are permitted in both directions, so the key sets are compared as well. + */ +const TAB_SHAPE_CHECK = ` +type ExpectedGoogleDocTab = { + id: string; title: string; parentTabId?: string; index: number; nestingLevel: number; +}; +type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const tabShapeIsExact: Mutual = true; +const tabKeysAreExact: Mutual = true; +`; + +function docBundle(): string { + return [ + source("docs-read-types.txt"), + stripTypeModulePrefix(source("docs-types.txt"), DOCS_TYPES_MODULE_PREFIX), + ].join("\n"); +} + +function driveBundle(): string { + return [ + source("docs-read-types.txt"), + source("sheets-types.txt"), + stripTypeModulePrefix(source("drive-types.txt"), DRIVE_TYPES_MODULE_PREFIX), + ].join("\n"); +} + describe("embedded agent declarations", () => { it("compiles the exact Google Doc agent declaration bundle without module dependencies", () => { - const types = [ - source("docs-read-types.txt"), - stripTypeModulePrefix(source("docs-types.txt"), DOCS_TYPES_MODULE_PREFIX), - ].join("\n"); - - expect(compileAgentTypes(types)).toEqual([]); + expect(compileAgentTypes(docBundle())).toEqual([]); }); it("compiles the exact Google Drive agent declaration bundle without module dependencies", () => { - const types = [ - source("docs-read-types.txt"), - source("sheets-types.txt"), - stripTypeModulePrefix(source("drive-types.txt"), DRIVE_TYPES_MODULE_PREFIX), - ].join("\n"); + expect(compileAgentTypes(driveBundle())).toEqual([]); + }); - expect(compileAgentTypes(types)).toEqual([]); + it("declares the flattened tab contract on the canonical read session", () => { + const readTypes = source("docs-read-types.d.ts"); + expect(readTypes).toContain("export type GoogleDocTab = {"); + expect(readTypes).toContain("listTabs(): Promise;"); + expect(readTypes).toContain("getContent(tabId?: string): Promise;"); }); + it.each([["Doc", docBundle], ["Drive", driveBundle]] as const)( + "carries the exact tab shape and its selection guidance into the %s bundle", + (_name, bundle) => { + const types = bundle(); + for (const phrase of TAB_GUIDANCE) expect(types).toContain(phrase); + expect(compileAgentTypes(types + TAB_SHAPE_CHECK)).toEqual([]); + }, + ); + it("keeps Drive Docs authority read-only", () => { const readTypes = source("docs-read-types.d.ts"); expect(readTypes).toContain("export interface GoogleDocReadSession"); expect(readTypes).not.toContain("replaceText"); expect(readTypes).not.toContain("appendText"); - expect(source("docs-types.d.ts")).toContain( + const writeTypes = source("docs-types.d.ts"); + expect(writeTypes).toContain( "export interface GoogleDocSession extends GoogleDocReadSession", ); + expect(writeTypes).toContain( + "replaceText(oldMarkdown: string, newMarkdown: string, tabId?: string): Promise;", + ); + expect(writeTypes).toContain("appendText(markdown: string, tabId?: string): Promise;"); }); it("hands out only read-only native sessions from Drive", () => { diff --git a/packages/gatekeeper-google/__tests__/worker.ts b/packages/gatekeeper-google/__tests__/worker.ts index aad8302754..b1147f1817 100644 --- a/packages/gatekeeper-google/__tests__/worker.ts +++ b/packages/gatekeeper-google/__tests__/worker.ts @@ -5,7 +5,7 @@ import type { } from "@gadgets/workshop-shared/gatekeeper"; import { TestGitCache } from "./test-git-cache"; import type { GoogleAccessToken } from "../src/google-api"; -import type { GoogleDocSession } from "../src/docs-types"; +import type { GoogleDocSession, GoogleDocTab } from "../src/docs-types"; import type { GoogleDocGatekeeperImpl as GoogleDocGatekeeper } from "../src/google"; export { default, GoogleDocGatekeeperImpl } from "../src/google"; @@ -20,15 +20,20 @@ type GatekeeperProps = { userObjectId: string; documentId: string }; class TestApprovalQueue extends RpcTarget implements ApprovalQueue { actionId?: number; + actionDescription?: string; + readonly observations: string[] = []; - async authorizeObservation(_description: ObservationDescription): Promise {} + async authorizeObservation(description: ObservationDescription): Promise { + this.observations.push(description.description); + } async getGitCache(): Promise { throw new Error("Unexpected git cache access"); } - async submitAction(actionId: number, _description: ActionDescription): Promise { + async submitAction(actionId: number, description: ActionDescription): Promise { this.actionId = actionId; + this.actionDescription = description.description; } async bindHook( @@ -41,6 +46,19 @@ class TestApprovalQueue extends RpcTarget implements ApprovalQueue { } export class TestHooks extends DurableObject { + #lastActionDescription = ""; + #lastObservations: string[] = []; + + /** The approval description of the edit most recently submitted through these hooks. */ + get lastActionDescription(): string { + return this.#lastActionDescription; + } + + /** Observation descriptions authorized by the most recent session, successful or not. */ + get lastObservations(): string[] { + return this.#lastObservations; + } + #gatekeeper(facetName: string) { let userObjectId = this.ctx.exports.UserAccount.idFromName("test-user").toString(); return this.ctx.facets.get(facetName, () => ({ @@ -50,37 +68,60 @@ export class TestHooks extends DurableObject { })); } - async submitAppend(facetName: string, markdown: string): Promise { + async #withSession( + facetName: string, + body: (session: GoogleDocSession, queue: TestApprovalQueue) => Promise, + ): Promise { let queue = new TestApprovalQueue(); - { - using approvalQueue = new RpcStub(queue); - using session = await this.#gatekeeper(facetName).startSession( - approvalQueue as unknown as ApprovalQueue, - ) as GoogleDocSession & Disposable; - await session.appendText(markdown); + using approvalQueue = new RpcStub(queue); + using session = await this.#gatekeeper(facetName).startSession( + approvalQueue as unknown as ApprovalQueue, + ) as GoogleDocSession & Disposable; + try { + // Awaited inside the scope: `return body(...)` would dispose both stubs mid-call. + return await body(session, queue); + } finally { + this.#lastActionDescription = queue.actionDescription ?? ""; + this.#lastObservations = queue.observations; } - if (queue.actionId === undefined) throw new Error("Action was not submitted"); - return queue.actionId; + } + + /** Run one edit and return the action ID it queued. */ + async #submit( + facetName: string, + edit: (session: GoogleDocSession) => Promise, + ): Promise { + return this.#withSession(facetName, async (session, queue) => { + await edit(session); + if (queue.actionId === undefined) throw new Error("Action was not submitted"); + return queue.actionId; + }); + } + + async submitAppend(facetName: string, markdown: string, tabId?: string): Promise { + return this.#submit(facetName, session => session.appendText(markdown, tabId)); + } + + async submitReplace( + facetName: string, oldMarkdown: string, newMarkdown: string, tabId?: string, + ): Promise { + return this.#submit( + facetName, session => session.replaceText(oldMarkdown, newMarkdown, tabId)); } /** The `lastModified` a metadata read reports, as epoch milliseconds. */ async readMetadata(facetName: string): Promise { - using approvalQueue = new RpcStub(new TestApprovalQueue()); - using session = await this.#gatekeeper(facetName).startSession( - approvalQueue as unknown as ApprovalQueue, - ) as GoogleDocSession & Disposable; - let metadata = await session.getMetadata(); - return metadata.lastModified.valueOf(); + return this.#withSession( + facetName, async session => (await session.getMetadata()).lastModified.valueOf()); } - /** The simulated document content a read reports. */ - async readContent(facetName: string): Promise { - using approvalQueue = new RpcStub(new TestApprovalQueue()); - using session = await this.#gatekeeper(facetName).startSession( - approvalQueue as unknown as ApprovalQueue, - ) as GoogleDocSession & Disposable; - let content = await session.getContent(); - return content; + /** The simulated content of one tab. */ + async readContent(facetName: string, tabId?: string): Promise { + return this.#withSession(facetName, session => session.getContent(tabId)); + } + + async listTabs(facetName: string): Promise { + return this.#withSession(facetName, session => session.listTabs()); } async applyAction(facetName: string, actionId: number): Promise { diff --git a/packages/gatekeeper-google/__tests__/workerd/google-doc-actions.test.ts b/packages/gatekeeper-google/__tests__/workerd/google-doc-actions.test.ts index 9f94e5c519..a5277be2a1 100644 --- a/packages/gatekeeper-google/__tests__/workerd/google-doc-actions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/google-doc-actions.test.ts @@ -1,23 +1,51 @@ import { abortAllDurableObjects, env } from "cloudflare:test"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { googleDocActionTab } from "../../src/google"; + +/** Every write coordinate names the tab it applies to; tab bodies index independently. */ +type DocCoordinate = { tabId?: string }; type BatchRequest = { - createNamedRange?: { name: string }; + createNamedRange?: { name: string; range: DocCoordinate }; deleteNamedRange?: { namedRangeId: string }; - insertText?: { location: { index: number }; text: string }; + insertText?: { location: DocCoordinate & { index: number }; text: string }; + deleteContentRange?: { range: DocCoordinate & { startIndex: number; endIndex: number } }; + updateParagraphStyle?: { range: DocCoordinate }; + createParagraphBullets?: { range: DocCoordinate }; + updateTextStyle?: { range: DocCoordinate }; }; +/** One tab of the model document: its own text, and its place in the tab tree. */ +type ModelTab = { id: string; title: string; parentId?: string; text: string }; + +/** The tab a single-tab document has, and the one the tab-agnostic tests exercise. */ +const MAIN_TAB = "tab-1"; + /** A batch either carries the edit and its marker, or deletes a marker on its own. */ type BatchKind = "content" | "cleanup"; class DocsModel { - content = ""; cleanupFailures = 0; ambiguousContentResponses = 0; contentBatches = 0; maxMarkerCount = 0; + /** Google withholds `revisionId` from a caller without edit access. */ + editable = true; + /** Full `documents.get` calls, excluding the lightweight revision probe. */ + documentFetches = 0; + revisionProbes = 0; + driveFetches = 0; + /** Drive's modification time for the document. */ + driveModifiedTime = "2026-01-02T03:04:05Z"; + /** What Drive answers with instead of that time, if anything. */ + driveFailure: { status: number; reason?: string } | "malformed" | null = null; readonly deletedMarkerIds: string[] = []; - readonly markers = new Map(); + /** Marker ID to its name and owning tab. */ + readonly markers = new Map(); + /** Tab each write marker was anchored in, retained after cleanup deletes the marker. */ + readonly markerTabIds: string[] = []; + /** Every tab in preorder; the first is the one a single-tab document has. */ + readonly tabs: ModelTab[] = [{ id: MAIN_TAB, title: "Main", text: "" }]; #revision = 1; #nextMarkerId = 1; readonly #held = new Map void; released: Promise }>(); @@ -27,11 +55,28 @@ class DocsModel { this.fetch(input, init))); } - addMarker(name: string, id: string): void { - this.markers.set(id, name); + addMarker(name: string, id: string, tabId = MAIN_TAB): void { + this.markers.set(id, { name, tabId }); this.#recordMarkerCount(); } + addTab(id: string, title: string, parentId: string, text = ""): void { + this.tabs.push({ id, title, parentId, text }); + } + + removeTab(id: string): void { + this.tabs.splice(this.tabs.findIndex(tab => tab.id === id), 1); + this.#revision++; + } + + setText(tabId: string, text: string): void { + this.#tab(tabId).text = text; + } + + text(tabId = MAIN_TAB): string { + return this.#tab(tabId).text; + } + clearMarkers(): void { this.markers.clear(); } @@ -51,17 +96,36 @@ class DocsModel { } /** A collaborator edit: the document changes without this gatekeeper writing to it. */ - externalEdit(): void { - this.content += "collaborator"; + externalEdit(tabId = MAIN_TAB, text?: string): void { + let tab = this.#tab(tabId); + tab.text = text ?? tab.text + "collaborator"; this.#revision++; } async fetch(input: string | URL | Request, init?: RequestInit): Promise { let url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.hostname === "www.googleapis.com") { + this.driveFetches++; + if (this.driveFailure === "malformed") { + return Response.json({ id: "doc-1", name: "Test document" }); + } + if (this.driveFailure) { + let { status, reason } = this.driveFailure; + return Response.json( + { error: { code: status, errors: reason ? [{ reason }] : [] } }, { status }); + } + return Response.json( + { id: "doc-1", name: "Test document", modifiedTime: this.driveModifiedTime }); + } if (url.hostname !== "docs.googleapis.com") { throw new Error(`Unexpected provider request: ${url}`); } - if (!url.pathname.endsWith(":batchUpdate")) return Response.json(this.#document()); + if (!url.pathname.endsWith(":batchUpdate")) { + let fields = url.searchParams.get("fields"); + if (fields === null) this.documentFetches++; + else if (fields === "revisionId") this.revisionProbes++; + return Response.json(this.#document()); + } let body = JSON.parse(String(init?.body)) as { requests: BatchRequest[]; @@ -81,23 +145,40 @@ class DocsModel { let replies: unknown[] = []; let hasContent = false; for (const request of body.requests) { - if (request.createNamedRange) { - let id = `marker-${this.#nextMarkerId++}`; - this.addMarker(request.createNamedRange.name, id); - replies.push({ createNamedRange: { namedRangeId: id } }); - } else if (request.deleteNamedRange) { + if (request.deleteNamedRange) { this.markers.delete(request.deleteNamedRange.namedRangeId); this.deletedMarkerIds.push(request.deleteNamedRange.namedRangeId); replies.push({}); - } else { - hasContent = true; - if (request.insertText) { - let offset = request.insertText.location.index - 1; - this.content = this.content.slice(0, offset) + request.insertText.text + - this.content.slice(offset); - } - replies.push({}); + continue; + } + + // Google resolves an unqualified coordinate against a default tab, so a request that omits + // the ID would edit whichever tab that happens to be. + let coordinate = request.createNamedRange?.range ?? request.insertText?.location ?? + request.deleteContentRange?.range ?? request.updateParagraphStyle?.range ?? + request.createParagraphBullets?.range ?? request.updateTextStyle?.range; + if (!coordinate?.tabId) { + throw new Error(`Google Docs request is missing tabId: ${JSON.stringify(request)}`); + } + let tab = this.#tab(coordinate.tabId); + + if (request.createNamedRange) { + let id = `marker-${this.#nextMarkerId++}`; + this.addMarker(request.createNamedRange.name, id, tab.id); + this.markerTabIds.push(tab.id); + replies.push({ createNamedRange: { namedRangeId: id } }); + continue; + } + + hasContent = true; + if (request.insertText) { + let offset = request.insertText.location.index - 1; + tab.text = tab.text.slice(0, offset) + request.insertText.text + tab.text.slice(offset); + } else if (request.deleteContentRange) { + let { startIndex, endIndex } = request.deleteContentRange.range; + tab.text = tab.text.slice(0, startIndex - 1) + tab.text.slice(endIndex - 1); } + replies.push({}); } if (hasContent) this.contentBatches++; this.#revision++; @@ -126,36 +207,51 @@ class DocsModel { this.maxMarkerCount = Math.max(this.maxMarkerCount, this.markers.size); } - #document() { - let text = `${this.content}\n`; - let grouped: Record = {}; - for (const [namedRangeId, name] of this.markers) { - (grouped[name] ??= { namedRanges: [] }).namedRanges.push({ namedRangeId, name }); + #tab(id: string): ModelTab { + let tab = this.tabs.find(candidate => candidate.id === id); + if (!tab) throw new Error(`Google Docs has no tab "${id}"`); + return tab; + } + + #documentTab(tab: ModelTab): unknown { + let text = `${tab.text}\n`; + let namedRanges: Record = {}; + for (const [namedRangeId, marker] of this.markers) { + if (marker.tabId !== tab.id) continue; + (namedRanges[marker.name] ??= { namedRanges: [] }) + .namedRanges.push({ namedRangeId, name: marker.name }); } + return { + tabProperties: { tabId: tab.id, title: tab.title }, + documentTab: { + body: { + content: [{ + startIndex: 1, + endIndex: text.length + 1, + paragraph: { + elements: [{ + startIndex: 1, endIndex: text.length + 1, + textRun: { content: text, textStyle: {} }, + }], + paragraphStyle: { namedStyleType: "NORMAL_TEXT" }, + }, + }], + }, + lists: {}, + namedRanges, + }, + childTabs: this.tabs.filter(child => child.parentId === tab.id) + .map(child => this.#documentTab(child)), + }; + } + + #document() { return { documentId: "doc-1", title: "Test document", - revisionId: `revision-${this.#revision}`, - tabs: [{ - documentTab: { - body: { - content: [{ - startIndex: 1, - endIndex: text.length + 1, - paragraph: { - elements: [{ - startIndex: 1, endIndex: text.length + 1, - textRun: { content: text, textStyle: {} }, - }], - paragraphStyle: { namedStyleType: "NORMAL_TEXT" }, - }, - }], - }, - lists: {}, - namedRanges: grouped, - }, - childTabs: [], - }], + ...this.editable ? { revisionId: `revision-${this.#revision}` } : {}, + tabs: this.tabs.filter(tab => tab.parentId === undefined) + .map(tab => this.#documentTab(tab)), }; } } @@ -178,7 +274,7 @@ describe("Google Doc write receipts", () => { await hooks().applyAction("normal", actionId); - expect(docs.content).toContain("first"); + expect(docs.text()).toContain("first"); expect(docs.contentBatches).toBe(1); expect(docs.deletedMarkerIds).toEqual(["marker-1"]); expect(docs.markers.size).toBe(0); @@ -214,8 +310,8 @@ describe("Google Doc write receipts", () => { let secondId = await hooks().submitAppend("restart", "second"); await hooks().applyAction("restart", secondId); - expect(docs.content).toContain("first"); - expect(docs.content).toContain("second"); + expect(docs.text()).toContain("first"); + expect(docs.text()).toContain("second"); expect(docs.contentBatches).toBe(2); expect(docs.maxMarkerCount).toBe(1); expect(docs.markers.size).toBe(0); @@ -285,7 +381,7 @@ describe("Google Doc write receipts", () => { expect(await first).toBeNull(); expect(await second).toMatch(/Unknown pending/); expect(docs.contentBatches).toBe(1); - expect(docs.content.match(/first/g)).toHaveLength(1); + expect(docs.text().match(/first/g)).toHaveLength(1); expect(docs.markers.size).toBe(0); }); @@ -351,4 +447,305 @@ describe("Google Doc metadata", () => { expect(await hooks().readMetadata("metadata-pending")).toBeGreaterThan(baseline); }); + + // Without edit access Google reports no revision, so Drive's own timestamp is the only signal + // that a collaborator changed anything. + it("reports Drive's modification time when there is no revision", async () => { + let docs = new DocsModel(); + docs.editable = false; + docs.install(); + + let first = await hooks().readMetadata("metadata-read-only"); + expect(first).toBe(new Date("2026-01-02T03:04:05Z").valueOf()); + + await scheduler.wait(2); + expect(await hooks().readMetadata("metadata-read-only")).toBe(first); + + docs.driveModifiedTime = "2026-01-02T04:00:00Z"; + expect(await hooks().readMetadata("metadata-read-only")) + .toBe(new Date("2026-01-02T04:00:00Z").valueOf()); + }); + + it("holds the modification time steady when Drive metadata is not granted", async () => { + let docs = new DocsModel(); + docs.editable = false; + docs.driveFailure = { status: 403, reason: "insufficientPermissions" }; + docs.install(); + + let first = await hooks().readMetadata("metadata-no-drive"); + await scheduler.wait(2); + + expect(await hooks().readMetadata("metadata-no-drive")).toBe(first); + expect(docs.driveFetches).toBe(2); + }); + + // Dating the document from a transient failure would report it unchanged for as long as Drive + // stayed unhealthy, and the stored observation would outlive the incident. + it.each([ + ["an outage", "metadata-drive-outage", { status: 500 }], + ["a quota refusal", "metadata-drive-quota", { status: 403, reason: "userRateLimitExceeded" }], + ["a malformed reply", "metadata-drive-malformed", "malformed"], + ] as const)("fails a metadata read rather than dating a document from %s", async ( + _case, facetName, failure, + ) => { + let docs = new DocsModel(); + docs.editable = false; + docs.driveFailure = failure; + docs.install(); + + await expect(Promise.resolve(hooks().readMetadata(facetName))).rejects.toThrow(); + + docs.driveFailure = null; + expect(await hooks().readMetadata(facetName)) + .toBe(new Date("2026-01-02T03:04:05Z").valueOf()); + }); +}); + +// Google withholds revisionId from a caller without edit access, which is the ordinary case for +// a Doc shared read-only. +describe("Google Doc with no revision ID", () => { + it("reuses its snapshot inside the TTL and refetches once expired", async () => { + let docs = new DocsModel(); + docs.editable = false; + docs.install(); + + await hooks().readContent("no-revision"); + await hooks().readContent("no-revision"); + expect(docs.documentFetches).toBe(1); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 60_000); + docs.externalEdit(MAIN_TAB, "collaborator edit"); + + expect(await hooks().readContent("no-revision")).toContain("collaborator edit"); + expect(docs.documentFetches).toBe(2); + // Nothing to compare, so the probe is not worth a request. + expect(docs.revisionProbes).toBe(0); + }); +}); + +/** A nested document whose three tabs all hold the same text, so an edit cannot be mistaken. */ +function nestedDocs(): DocsModel { + let docs = new DocsModel(); + docs.setText(MAIN_TAB, "shared"); + docs.addTab("details", "Details", MAIN_TAB, "shared"); + docs.addTab("metrics", "Metrics", "details", "shared"); + docs.install(); + return docs; +} + +describe("Google Doc tab isolation", () => { + it("lists the tab tree and appends only to the tab it names", async () => { + let docs = nestedDocs(); + + expect(await hooks().listTabs("tabs-append")).toEqual([ + { id: MAIN_TAB, title: "Main", index: 0, nestingLevel: 0 }, + { id: "details", title: "Details", parentTabId: MAIN_TAB, index: 0, nestingLevel: 1 }, + { id: "metrics", title: "Metrics", parentTabId: "details", index: 0, nestingLevel: 2 }, + ]); + + let actionId = await hooks().submitAppend("tabs-append", "added", "metrics"); + expect(await hooks().readContent("tabs-append", "metrics")).toContain("added"); + expect(await hooks().readContent("tabs-append", MAIN_TAB)).not.toContain("added"); + + expect(await hooks().applyAction("tabs-append", actionId)).toBeNull(); + + // The marker must be anchored in the tab that was edited: a marker left in another tab is + // invisible to the retry lookup, which would re-apply the write after a lost response. + expect(docs.markerTabIds).toEqual(["metrics"]); + expect(docs.text("metrics")).toContain("added"); + expect(docs.text(MAIN_TAB)).toBe("shared"); + expect(docs.text("details")).toBe("shared"); + }); + + it("replaces identical text in the selected tab only", async () => { + let docs = nestedDocs(); + let actionId = await hooks().submitReplace("tabs-replace", "shared", "changed", "metrics"); + + expect(await hooks().applyAction("tabs-replace", actionId)).toBeNull(); + + expect(docs.markerTabIds).toEqual(["metrics"]); + expect(docs.text("metrics")).toBe("changed"); + expect(docs.text(MAIN_TAB)).toBe("shared"); + expect(docs.text("details")).toBe("shared"); + }); + + it("submits nothing for an omitted or unknown tab", async () => { + let docs = nestedDocs(); + + await expect(Promise.resolve(hooks().submitAppend("tabs-selector", "added"))) + .rejects.toThrow(/tabId is required for documents with multiple tabs/); + await expect(Promise.resolve( + hooks().submitReplace("tabs-selector", "shared", "changed", "ghost"), + )).rejects.toThrow(/no tab with ID "ghost"/); + + expect(docs.contentBatches).toBe(0); + expect(docs.text("metrics")).toBe("shared"); + }); + + // A failed edit still tells the caller whether a tab, or the text in it, exists. Leaving the + // write paths ungated would let a caller ask through appendText what getContent refuses. + const GENERIC_READ = "Read the content of one tab of the document."; + + it.each([ + ["an omitted tab", () => hooks().submitAppend("tabs-write-oracle", "added"), + /tabId is required for documents with multiple tabs/], + ["an unknown tab", () => hooks().submitAppend("tabs-write-oracle", "added", "ghost"), + /no tab with ID "ghost"/], + ["unmatched text", + () => hooks().submitReplace("tabs-write-oracle", "absent", "changed", "metrics"), + /was not found in the current simulated tab/], + ] as const)("authorizes a generic observation when an edit fails on %s", async ( + _case, submit, message, + ) => { + let docs = nestedDocs(); + + await expect(Promise.resolve(submit())).rejects.toThrow(message); + + expect(await hooks().lastObservations).toEqual([GENERIC_READ]); + expect(docs.contentBatches).toBe(0); + }); + + it("is not suppressed by a same-named write marker in another tab", async () => { + vi.spyOn(crypto, "randomUUID").mockReturnValue("fixed-write-id"); + let docs = nestedDocs(); + docs.addMarker("gadgets-write-fixed-write-id", "other-1", MAIN_TAB); + + let actionId = await hooks().submitAppend("tabs-foreign-marker", "added", "metrics"); + expect(await hooks().applyAction("tabs-foreign-marker", actionId)).toBeNull(); + + expect(docs.contentBatches).toBe(1); + expect(docs.text("metrics")).toContain("added"); + expect(docs.text(MAIN_TAB)).toBe("shared"); + }); + + it("refuses an edit whose tab is deleted before approval", async () => { + let docs = nestedDocs(); + let actionId = await hooks().submitAppend("tabs-deleted", "added", "metrics"); + + docs.removeTab("metrics"); + expect(await hooks().applyAction("tabs-deleted", actionId)).toBe( + 'appendText: no tab with ID "metrics" exists in this document. ' + + "Call listTabs() to refresh the tab list."); + + // Approving it again must not report success for a write that never happened, and must not + // decay into "unknown action" either — rejecting is the way out. + let repeated = "Pending Google Doc edit could not be applied: " + + 'appendText: no tab with ID "metrics" exists in this document. ' + + "Call listTabs() to refresh the tab list."; + expect(await hooks().applyAction("tabs-deleted", actionId)).toBe(repeated); + expect(await hooks().applyAction("tabs-deleted", actionId)).toBe(repeated); + + expect(docs.contentBatches).toBe(0); + expect(docs.text(MAIN_TAB)).toBe("shared"); + expect(docs.text("details")).toBe("shared"); + }); + + // Actions are approved in one global order, but each replays against only its own tab, so + // neither edit may shift or shadow the other. + it("replays edits queued on different tabs independently", async () => { + let docs = nestedDocs(); + let metricsId = await hooks().submitAppend("tabs-interleaved", "alpha", "metrics"); + let mainId = await hooks().submitAppend("tabs-interleaved", "beta", MAIN_TAB); + + let metricsPreview = await hooks().readContent("tabs-interleaved", "metrics"); + let mainPreview = await hooks().readContent("tabs-interleaved", MAIN_TAB); + expect(metricsPreview).toContain("alpha"); + expect(metricsPreview).not.toContain("beta"); + expect(mainPreview).toContain("beta"); + expect(mainPreview).not.toContain("alpha"); + + expect(await hooks().applyAction("tabs-interleaved", metricsId)).toBeNull(); + expect(await hooks().applyAction("tabs-interleaved", mainId)).toBeNull(); + + expect(docs.text("metrics")).toBe("shared\nalpha"); + expect(docs.text(MAIN_TAB)).toBe("shared\nbeta"); + expect(docs.text("details")).toBe("shared"); + }); + + it("invalidates only the edit whose own tab moved", async () => { + let docs = nestedDocs(); + await hooks().submitReplace("tabs-invalidate", "shared", "changed", "metrics"); + let mainId = await hooks().submitAppend("tabs-invalidate", "beta", MAIN_TAB); + + // A collaborator rewrites Metrics, so the replace no longer matches. The append targets a + // different tab whose text never moved, so it must survive and stop waiting behind it. + docs.externalEdit("metrics", "rewritten"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 60_000); + + let metricsPreview = await hooks().readContent("tabs-invalidate", "metrics"); + expect(metricsPreview).not.toContain("changed"); + expect(metricsPreview).not.toContain("beta"); + expect(await hooks().readContent("tabs-invalidate", MAIN_TAB)).toContain("beta"); + + expect(await hooks().applyAction("tabs-invalidate", mainId)).toBeNull(); + + expect(docs.text(MAIN_TAB)).toBe("shared\nbeta"); + expect(docs.text("metrics")).toBe("rewritten"); + }); + + // Titles are user-authored, need not be unique and may be empty, so the approval a user + // consents to has to carry the ID the write actually targets. + it("names the target tab by ID when two tabs share a title", async () => { + let docs = new DocsModel(); + docs.setText(MAIN_TAB, "shared"); + docs.addTab("notes-a", "Notes", MAIN_TAB, "shared"); + docs.addTab("notes-b", "Notes", MAIN_TAB, "shared"); + docs.install(); + + await hooks().submitAppend("tabs-duplicate-title", "added", "notes-b"); + + expect(await hooks().lastActionDescription).toContain('tab "Notes" (notes-b)'); + }); +}); + +// A stored edit with no tab predates tab support. No current write path produces one, so this is +// the only place the migration refusal can be reached. +describe("Google Doc edits stored before tab support", () => { + function tabSnapshot(tabId: string, title: string) { + return { + tabId, title, index: 0, nestingLevel: 0, + markdown: "shared\n", sourceMap: { blocks: [] }, bodyEndIndex: 8, + committedWriteIds: [], + }; + } + + const snapshot = { + title: "Test document", + revisionId: "revision-1", + tabs: [tabSnapshot(MAIN_TAB, "Main")], + fetchedAt: 0, + }; + + const storedAppend = { + type: "appendText" as const, + documentId: "doc-1", + submittedAt: 0, + baseRevisionId: "revision-1", + markdown: "added", + }; + + // The old code refused to read a multi-tab document, so a stored record was approved against + // the one tab such a document had. + it("retargets a record naming no tab when the document still has exactly one", () => { + expect(googleDocActionTab(snapshot, storedAppend).tabId).toBe(MAIN_TAB); + }); + + it("refuses a record naming no tab once the document has gained tabs", () => { + let grown = { ...snapshot, tabs: [...snapshot.tabs, tabSnapshot("second", "Second")] }; + expect(() => googleDocActionTab(grown, storedAppend)).toThrow( + "Pending Google Doc edit predates tab support and the document has gained tabs since, " + + "so the tab it was approved against is unknown. Reject it and retry on a selected tab."); + }); + + it("refuses a vanished tab rather than retargeting to the first", () => { + expect(() => googleDocActionTab(snapshot, { ...storedAppend, tabId: "ghost" })).toThrow( + 'appendText: no tab with ID "ghost" exists in this document. ' + + "Call listTabs() to refresh the tab list."); + }); + + it("resolves a record that names a live tab", () => { + expect(googleDocActionTab(snapshot, { ...storedAppend, tabId: MAIN_TAB }).title).toBe("Main"); + }); }); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index d85ef8e490..b0336357a1 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -12,6 +12,10 @@ import { GoogleSheetsApi } from "../../src/sheets-api"; const DOC_MIME = "application/vnd.google-apps.document"; const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; let providerUrls: string[]; +/** The document the provider currently serves; a test may replace it mid-session. */ +let providerTabs: unknown[]; +/** Absent for a document the caller cannot edit, as Google returns it. */ +let providerRevision: string | undefined; async function getAccessToken(): Promise { return "access-token"; @@ -49,8 +53,50 @@ function providerFile(id: string, mimeType: string) { }; } -function installProvider() { +/** One provider tab: the section break every body opens with, then an optional paragraph. */ +function docTab(tabId: string, title: string, text: string, childTabs: unknown[] = []) { + const paragraph = `${text}\n`; + return { + tabProperties: { tabId, title }, + documentTab: { + body: { + content: [ + { startIndex: 0, endIndex: 1, sectionBreak: {} }, + ...text ? [{ + startIndex: 1, + endIndex: paragraph.length + 1, + paragraph: { + elements: [{ + startIndex: 1, + endIndex: paragraph.length + 1, + textRun: { content: paragraph, textStyle: {} }, + }], + paragraphStyle: { namedStyleType: "NORMAL_TEXT" }, + }, + }] : [], + ], + }, + lists: {}, + namedRanges: {}, + }, + childTabs, + }; +} + +/** Two roots, a child and a grandchild — the shape `listTabs()` must flatten in preorder. */ +const NESTED_TABS = [ + docTab("overview", "Overview", "Overview body", [ + docTab("details", "Details", "Details body", [ + docTab("metrics", "Metrics", "Metrics body"), + ]), + ]), + docTab("appendix", "Appendix", "Appendix body"), +]; + +function installProvider(tabs: unknown[] = [docTab("solo", "Solo", "")]) { const urls: string[] = []; + providerTabs = tabs; + providerRevision = "revision-1"; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { const url = new URL(input instanceof Request ? input.url : input.toString()); urls.push(url.toString()); @@ -66,11 +112,8 @@ function installProvider() { return Response.json({ documentId: "doc-1", title: "Quarterly plan", - revisionId: "revision-1", - tabs: [{ - documentTab: { body: { content: [] }, lists: {}, namedRanges: {} }, - childTabs: [], - }], + ...providerRevision === undefined ? {} : { revisionId: providerRevision }, + tabs: providerTabs, }); } throw new Error(`Unexpected provider request: ${url.origin}${url.pathname}`); @@ -78,6 +121,14 @@ function installProvider() { return urls; } +/** Full `documents.get` calls, excluding the lightweight revision check. */ +function docFetches(): number { + return providerUrls.filter(url => { + const { hostname, searchParams } = new URL(url); + return hostname === "docs.googleapis.com" && !searchParams.has("fields"); + }).length; +} + function newSession() { const queue = new TestApprovalQueue(); const queueStub: RpcStub = new RpcStub(queue); @@ -98,7 +149,10 @@ function newSession() { beforeEach(() => { providerUrls = installProvider(); }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); describe("Drive nested native sessions", () => { it("pipelines a Doc call before resolving its disposable child stub", async () => { @@ -150,3 +204,134 @@ describe("Drive nested native sessions", () => { expect(queue.observations.at(-1)?.title).toBe("Read Google Drive metadata"); }); }); + +describe("Drive Doc tab selection", () => { + beforeEach(() => { + providerUrls = installProvider(NESTED_TABS); + }); + + it("flattens the tab tree in preorder with derived ancestry", async () => { + using session = newSession().session; + using doc = await session.openGoogleDoc("doc-1"); + + expect(await doc.listTabs()).toEqual([ + { id: "overview", title: "Overview", index: 0, nestingLevel: 0 }, + { id: "details", title: "Details", parentTabId: "overview", index: 0, nestingLevel: 1 }, + { id: "metrics", title: "Metrics", parentTabId: "details", index: 0, nestingLevel: 2 }, + { id: "appendix", title: "Appendix", index: 1, nestingLevel: 0 }, + ]); + }); + + it("reads only the selected tab and fetches the document once", async () => { + const { queue, session } = newSession(); + using owned = session; + using doc = await owned.openGoogleDoc("doc-1"); + + await doc.listTabs(); + expect(await doc.getContent("metrics")).toBe("Metrics body\n"); + expect(await doc.getContent("appendix")).toBe("Appendix body\n"); + + expect(docFetches()).toBe(1); + expect(queue.observations.map(({ title }) => title)).toEqual([ + "Open Google Doc from Google Drive", "List Google Doc tabs", + "Read Google Doc content", "Read Google Doc content", + ]); + expect(queue.observations.at(-1)?.description).toContain('tab "Appendix" (appendix)'); + }); + + // Reads issued without awaiting the first must share one provider revision, or they can + // observe different documents and the later response can be the older one. + it("fetches the document once for concurrent reads", async () => { + using session = newSession().session; + using doc = await session.openGoogleDoc("doc-1"); + + const [tabs, content] = await Promise.all([doc.listTabs(), doc.getContent("metrics")]); + + expect(tabs).toHaveLength(4); + expect(content).toBe("Metrics body\n"); + expect(docFetches()).toBe(1); + }); + + // The session is a long-lived stub, so pinning it to the revision of its first read would hide + // every later collaborator edit -- and the selector error tells the caller to call listTabs(), + // which could not refresh anything. + it("sees a collaborator's new tab once the snapshot expires", async () => { + using session = newSession().session; + using doc = await session.openGoogleDoc("doc-1"); + expect(await doc.listTabs()).toHaveLength(4); + + providerTabs = [...NESTED_TABS, docTab("addendum", "Addendum", "Addendum body")]; + providerRevision = "revision-2"; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 60_000); + + expect(await doc.listTabs()).toHaveLength(5); + expect(await doc.getContent("addendum")).toBe("Addendum body\n"); + expect(docFetches()).toBe(2); + }); + + it("reuses the expired snapshot when the revision is unchanged", async () => { + using session = newSession().session; + using doc = await session.openGoogleDoc("doc-1"); + await doc.listTabs(); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 60_000); + + expect(await doc.getContent("metrics")).toBe("Metrics body\n"); + expect(docFetches()).toBe(1); + expect(providerUrls.some(url => new URL(url).searchParams.get("fields") === "revisionId")) + .toBe(true); + }); + + // Google omits revisionId unless the caller can edit, which is the normal case for a Doc + // opened read-only through Drive. Two absent revisions must not compare as unchanged. + it("refetches a document that has no revision ID", async () => { + providerRevision = undefined; + using session = newSession().session; + using doc = await session.openGoogleDoc("doc-1"); + expect(await doc.listTabs()).toHaveLength(4); + + providerTabs = [...NESTED_TABS, docTab("addendum", "Addendum", "Addendum body")]; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 60_000); + + expect(await doc.listTabs()).toHaveLength(5); + expect(docFetches()).toBe(2); + // Nothing to compare, so the revision probe is not worth a request. + expect(providerUrls.some(url => new URL(url).searchParams.get("fields") === "revisionId")) + .toBe(false); + }); + + it("pipelines a tab read before its session stub resolves", async () => { + using session = newSession().session; + + const docPromise = session.openGoogleDoc("doc-1"); + const contentPromise = docPromise.getContent("details"); + using doc = await docPromise; + + expect(await contentPromise).toBe("Details body\n"); + doc[Symbol.dispose](); + await expect(Promise.resolve(doc.listTabs())).rejects.toThrow(); + }); + + it.each([ + [undefined, "getContent: tabId is required for documents with multiple tabs. " + + "Call listTabs() to choose a tab."], + ["ghost", 'getContent: no tab with ID "ghost" exists in this document. ' + + "Call listTabs() to refresh the tab list."], + ] as const)("fails closed on selector %s", async (tabId, message) => { + const { queue, session } = newSession(); + using owned = session; + using doc = await owned.openGoogleDoc("doc-1"); + + await expect(Promise.resolve(doc.getContent(tabId))).rejects.toThrow(message); + + // The selector error says whether a tab exists, so the attempt is itself an observation -- + // recorded, but naming no tab, since none was disclosed. + expect(queue.observations.at(-1)).toMatchObject({ + title: "Read Google Doc content", + description: "Read the content of one tab of the document.", + }); + }); +}); diff --git a/packages/gatekeeper-google/src/docs-api.ts b/packages/gatekeeper-google/src/docs-api.ts index 2f51963e5c..81d491075a 100644 --- a/packages/gatekeeper-google/src/docs-api.ts +++ b/packages/gatekeeper-google/src/docs-api.ts @@ -12,18 +12,44 @@ import { readGoogleJson } from "./google-response"; // use are included. // --------------------------------------------------------------------------- -/** Top-level document response from `documents.get`. */ +/** + * A document from `documents.get`, with Google's recursive tab tree flattened in preorder. + * + * Content lives on the tabs: a document itself has none, and every tab body has its own index + * space, so nothing outside one tab may be read or written with that tab's indices. + */ export type GoogleDocsDocument = { documentId: string; title: string; - revisionId: string; + /** Google populates this only for callers with edit access, so a reader sees no revision. */ + revisionId?: string; + /** Every tab, depth-first, parents before children. Never empty. */ + tabs: GoogleDocsTab[]; +} + +/** One tab's content and its place in the document's tab tree. */ +export type GoogleDocsTab = { + /** Google's immutable tab ID, which every write coordinate into this tab must carry. */ + tabId: string; + title: string; + /** The containing tab, absent for a top-level tab. */ + parentTabId?: string; + /** Position among the tabs sharing this parent. */ + index: number; + /** Depth in the tab tree; 0 for a top-level tab. */ + nestingLevel: number; body: { content: StructuralElement[] }; + /** List definitions this tab's paragraphs reference. */ lists: Record; - namedRanges: Record; + /** Named ranges anchored in this tab. */ + namedRanges: NamedRanges; } +/** Named ranges grouped by name, as `documents.get` returns them. */ +export type NamedRanges = Record + /** A list definition, referenced by paragraphs that are list items. */ export type DocList = { listProperties: { @@ -86,45 +112,79 @@ export type TextStyle = { link?: { url: string }; } -type GoogleDocsTabContent = Pick & { - lists?: GoogleDocsDocument["lists"]; - namedRanges?: GoogleDocsDocument["namedRanges"]; -}; - -type GoogleDocsTab = { - documentTab?: GoogleDocsTabContent; - childTabs?: GoogleDocsTab[]; -}; - type GoogleDocsResponse = Pick< GoogleDocsDocument, "documentId" | "title" | "revisionId" -> & { tabs?: GoogleDocsTab[] }; +> & { tabs?: unknown }; + +/** Where one write's marker range goes: one character at `rangeStart` inside tab `tabId`. */ +type GoogleDocsWriteMarker = { name: string; rangeStart: number; tabId: string }; + +const INVALID_TAB = "Google Docs returned an invalid tab"; -type GoogleDocsWriteMarker = { name: string; rangeStart: number }; +/** An optional provider collection, which must be a plain object when present. */ +function tabCollection(value: unknown): T { + if (value === undefined) return {} as T; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(INVALID_TAB); + return value as T; +} -function singleTabDocument(document: GoogleDocsResponse): GoogleDocsDocument { - let tabs = document.tabs; - if (!tabs || tabs.length === 0) { +/** + * Flatten Google's recursive `tabs`/`childTabs` tree into a preorder list. + * + * Ancestry, sibling order and depth are derived from the tree actually returned rather than read + * from `tabProperties`, and tab IDs must be unique document-wide, because everything downstream + * addresses a tab by ID alone. + */ +function normalizeDocumentTabs(tabs: unknown): GoogleDocsTab[] { + if (!Array.isArray(tabs) || tabs.length === 0) { throw new Error("Google Docs returned no document tab"); } - let [tab] = tabs; - if (tabs.length !== 1 || tab.childTabs?.length) { - throw new Error("Multi-tab Google Docs are not supported"); - } - let tabContent = tab.documentTab; - if (!tabContent) { - throw new Error("Google Docs returned a tab without document content"); - } + let normalized: GoogleDocsTab[] = []; + let seen = new Set(); + + let visit = (siblings: unknown[], parentTabId: string | undefined, nestingLevel: number) => { + for (let [index, raw] of siblings.entries()) { + if (!raw || typeof raw !== "object") throw new Error(INVALID_TAB); + let { tabProperties, documentTab, childTabs } = raw as Record; + if (!tabProperties || typeof tabProperties !== "object") throw new Error(INVALID_TAB); + let { tabId, title } = tabProperties as Record; + if (typeof tabId !== "string" || tabId.length === 0 || typeof title !== "string") { + throw new Error(INVALID_TAB); + } + if (seen.has(tabId)) throw new Error("Google Docs returned a duplicate tab ID"); + seen.add(tabId); + + if (!documentTab || typeof documentTab !== "object") throw new Error(INVALID_TAB); + let { body, lists, namedRanges } = documentTab as Record; + // A real body always holds at least a section break, and `bodyEndIndex` arithmetic assumes + // it: an empty one would place an append at index -1. + if (!body || typeof body !== "object" || !("content" in body) || + !Array.isArray(body.content) || body.content.length === 0) { + throw new Error(INVALID_TAB); + } + // Structural elements stay unvalidated here; the converter reads them defensively. + let content = body.content as StructuralElement[]; - return { - documentId: document.documentId, - title: document.title, - revisionId: document.revisionId, - body: tabContent.body, - lists: tabContent.lists ?? {}, - namedRanges: tabContent.namedRanges ?? {}, + normalized.push({ + tabId, + title, + ...parentTabId === undefined ? {} : { parentTabId }, + index, + nestingLevel, + body: { content }, + lists: tabCollection(lists), + namedRanges: tabCollection(namedRanges), + }); + + if (childTabs === undefined) continue; + if (!Array.isArray(childTabs)) throw new Error(INVALID_TAB); + visit(childTabs, tabId, nestingLevel + 1); + } }; + + visit(tabs, undefined, 0); + return normalized; } // --------------------------------------------------------------------------- @@ -152,17 +212,17 @@ export class GoogleDocsApi { }); } - /** Fetch and normalize a single-tab document. */ + /** Fetch a document and flatten its tab tree. */ async getDocument(documentId: string): Promise { - let document = await this.#request( + let { documentId: id, title, revisionId, tabs } = await this.#request( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}?includeTabsContent=true`, {}, "get document", ); - if (document.documentId !== documentId) { + if (id !== documentId) { throw new Error("Google Docs returned a different document"); } - return singleTabDocument(document); + return { documentId: id, title, revisionId, tabs: normalizeDocumentTabs(tabs) }; } /** @@ -188,14 +248,13 @@ export class GoogleDocsApi { } /** - * Lightweight revision check. Uses the `fields` query parameter to request - * only the revisionId, avoiding downloading the full document body. + * Lightweight revision check, requesting only the revisionId rather than the whole body. * - * If the API doesn't support field filtering (returns the full doc anyway), - * that's fine — we just parse revisionId from whatever comes back. + * Absent when the caller cannot edit the document, in which case there is no change token and + * a reader has to refetch to see whether anything moved. */ - async getRevisionId(documentId: string): Promise { - let data = await this.#request<{ revisionId: string }>( + async getRevisionId(documentId: string): Promise { + let data = await this.#request<{ revisionId?: string }>( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}?fields=revisionId`, {}, "get revision ID", @@ -217,6 +276,7 @@ export class GoogleDocsApi { range: { startIndex: writeMarker.rangeStart, endIndex: writeMarker.rangeStart + 1, + tabId: writeMarker.tabId, }, }, }, ...requests] diff --git a/packages/gatekeeper-google/src/docs-read-types.d.ts b/packages/gatekeeper-google/src/docs-read-types.d.ts index 0509e96cbc..31e41c0df8 100644 --- a/packages/gatekeeper-google/src/docs-read-types.d.ts +++ b/packages/gatekeeper-google/src/docs-read-types.d.ts @@ -7,11 +7,47 @@ export type DocMetadata = { lastModified: Date; } -/** Read-only access to one native Google Doc. */ +/** + * One tab of a native Google Doc. + * + * A document is a tree of tabs; `listTabs()` returns that tree flattened depth-first, parents + * before children, so `parentTabId` reconstructs the hierarchy without nested objects. + */ +export type GoogleDocTab = { + /** Immutable tab ID. Pass it to `getContent()` and to edits to target this tab. */ + id: string; + + /** Tab name, as shown in the document's tab list. */ + title: string; + + /** The tab that contains this one, absent for a top-level tab. */ + parentTabId?: string; + + /** Position among the tabs sharing this parent, counting from 0. */ + index: number; + + /** Depth in the tab tree: 0 for a top-level tab, 1 for its child, and so on. */ + nestingLevel: number; +} + +/** + * Read-only access to one native Google Doc. + * + * Each tab holds its own content. Call `listTabs()` before `getContent()` to see which tabs + * exist, then read one of them. + */ export interface GoogleDocReadSession { /** Return current document metadata. Works with any number of tabs. */ getMetadata(): Promise; - /** Return the document body as Markdown. Requires exactly one tab. */ - getContent(): Promise; + /** Return every tab in the document, flattened depth-first with parents before children. */ + listTabs(): Promise; + + /** + * Return one tab's content as Markdown. + * + * This reads exactly one tab and never combines tabs, so pass an ID returned by `listTabs()`. + * Omitting `tabId` is valid only when `listTabs()` returns exactly one tab. + */ + getContent(tabId?: string): Promise; } diff --git a/packages/gatekeeper-google/src/docs-types.d.ts b/packages/gatekeeper-google/src/docs-types.d.ts index 95a5237358..8c7a14b437 100644 --- a/packages/gatekeeper-google/src/docs-types.d.ts +++ b/packages/gatekeeper-google/src/docs-types.d.ts @@ -1,25 +1,30 @@ import type { GoogleDocReadSession } from "./docs-read-types"; -export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types"; +export type { DocMetadata, GoogleDocReadSession, GoogleDocTab } from "./docs-read-types"; /** * Read/write access to one directly bound native Google Doc. - * Metadata works with any number of tabs; content conversion and edits require exactly one tab. + * + * Metadata covers the whole document; reads and edits each target exactly one tab, so call + * `listTabs()` first and pass the ID of the tab to act on. */ export interface GoogleDocSession extends GoogleDocReadSession { /** - * Find `oldMarkdown` in the current document content and replace it with `newMarkdown`. + * Find `oldMarkdown` in tab `tabId` and replace it with `newMarkdown`. * Both parameters are Markdown text. * + * Matching and editing are local to that one tab: pass an ID returned by `listTabs()`, and omit + * `tabId` only when `listTabs()` returns exactly one tab. + * * The match must be unique -- if `oldMarkdown` appears zero times or more than once in the - * document, an error is thrown. If the match is ambiguous, include more surrounding context + * selected tab, an error is thrown. If the match is ambiguous, include more surrounding context * in `oldMarkdown` to disambiguate. * * The gatekeeper automatically trims unchanged leading and trailing text before sending the * edit to Google Docs, so it's fine (and encouraged) to include extra context in `oldMarkdown` * and `newMarkdown` for matching purposes. * - * The Markdown is mapped back to Google Docs operations using the document's source map. The + * The Markdown is mapped back to Google Docs operations using the tab's source map. The * following Markdown features are supported in `newMarkdown`: * - Headings (`# ` through `###### `) * - Bold (`**text**`) @@ -33,13 +38,13 @@ export interface GoogleDocSession extends GoogleDocReadSession { * * Unsupported Markdown features (tables, images, code blocks, etc.) are inserted as plain text. * - * A subsequent `getContent()` call reflects this replacement. + * A subsequent `getContent(tabId)` call reflects this replacement. */ - replaceText(oldMarkdown: string, newMarkdown: string): Promise; + replaceText(oldMarkdown: string, newMarkdown: string, tabId?: string): Promise; /** - * Append Markdown content to the end of the document. The same Markdown features as - * `replaceText()` are supported. + * Append Markdown content to the end of tab `tabId`. The same Markdown features and tab + * selection rules as `replaceText()` apply. */ - appendText(markdown: string): Promise; + appendText(markdown: string, tabId?: string): Promise; } diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 142a346874..32d9fb70bf 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -46,13 +46,21 @@ function requiredString(value: string | undefined, field: string): string { return value; } +/** Drive's `modifiedTime`, validated. */ +export function driveModifiedTime(file: DriveFile): Date { + let modifiedTime = new Date(requiredString(file.modifiedTime, "modifiedTime")); + if (Number.isNaN(modifiedTime.valueOf())) { + throw new Error("Google Drive returned an invalid modifiedTime"); + } + return modifiedTime; +} + /** Maps one validated provider file to the permanent agent-facing declaration. */ export function driveFileToEntry(file: DriveFile): DriveEntry { let mimeType = requiredString(file.mimeType, "mimeType"); let isFolder = mimeType === FOLDER_MIME_TYPE; let isShortcut = mimeType === SHORTCUT_MIME_TYPE; - let modifiedTime = new Date(requiredString(file.modifiedTime, "modifiedTime")); - if (Number.isNaN(modifiedTime.valueOf())) throw new Error("Google Drive returned an invalid modifiedTime"); + let modifiedTime = driveModifiedTime(file); let size: number | undefined; if (file.size !== undefined && !isFolder && !isShortcut) { diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 890e6d1658..7167e3ab13 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -7,18 +7,21 @@ import { type PreviewOAuthState, } from "@gadgets/gatekeeper-kit/preview-oauth"; import { exchangeAuthCode, getAccessToken, getGoogleAccountDescription, getGoogleVerifiedEmail, GoogleAccessToken, revokeGoogleToken } from "./google-api"; -import { GoogleDocSession, DocMetadata, type GoogleDocReadSession } from "./docs-types"; -import { GoogleDocsApi, type GoogleDocsDocument } from "./docs-api"; +import { GoogleDocSession, DocMetadata, type GoogleDocReadSession, type GoogleDocTab } from "./docs-types"; +import { GoogleDocsApi, type GoogleDocsDocument, type GoogleDocsTab } from "./docs-api"; import { GoogleSheetsApi } from "./sheets-api"; import type { GoogleSpreadsheetReadSession, GoogleSpreadsheetSession, SpreadsheetInfo, SpreadsheetRange, SpreadsheetValueMode, } from "./sheets-types"; -import { docToMarkdown, markdownToDocRequests, computeReplaceOperations, DocSnapshot } from "./markdown-converter"; -import { DriveApi } from "./drive-api"; +import { + computeReplaceOperations, docTabToMarkdown, markdownToDocRequests, type DocTabSnapshot, +} from "./markdown-converter"; +import { DriveApi, DriveApiRequestError } from "./drive-api"; import { driveObserverTracker } from "./drive-observers"; import { - DriveSessionCore, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, type DriveBindingScope, + DriveSessionCore, driveModifiedTime, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, + type DriveBindingScope, type DriveSessionCoreOptions, } from "./drive-session"; import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; @@ -1063,8 +1066,13 @@ class RpcCursor extends RpcTarget implements Cursor { type GoogleDocActionBase = { documentId: string; + /** + * The tab this edit targets. Absent only on records stored before tab support, which are + * invalidated rather than retargeted: the first tab is not necessarily the one they meant. + */ + tabId?: string; submittedAt: number; - baseRevisionId: string; + baseRevisionId?: string; writeId?: string; invalidatedReason?: string; } @@ -1084,7 +1092,9 @@ type GoogleDocAction = GoogleDocReplaceAction | GoogleDocAppendAction; const DOC_WRITE_RECEIPT_KEY = "docWriteReceipt"; const DOC_METADATA_REVISION_KEY = "docMetadataRevision"; -/** The last document read, replayed for 10s. Pending actions overlay it, so it outlives none. */ +/** The last document read, replayed for this long before its revision is rechecked. */ +const DOC_SNAPSHOT_TTL_MS = 10_000; +/** The last document read. Pending actions overlay it, so it outlives none. */ const DOC_SNAPSHOT_KEY = "docSnapshot"; /** Name prefix of the named range that marks one Gadgets write. Permanent: retries match on it. */ const WRITE_MARKER_PREFIX = "gadgets-write-"; @@ -1092,14 +1102,12 @@ const WRITE_MARKER_PREFIX = "gadgets-write-"; type GoogleDocWriteReceipt = { actionId: number; markerId: string }; /** The document revision this binding has already reported, and when it first saw it. */ -type GoogleDocMetadataRevision = { revisionId: string; observedAt: number }; +type GoogleDocMetadataRevision = { revisionId?: string; observedAt: number }; type GoogleDocNamedRange = { id: string; name: string }; -function googleDocNamedRanges(document: GoogleDocsDocument): GoogleDocNamedRange[] { +function googleDocNamedRanges(tab: GoogleDocsTab): GoogleDocNamedRange[] { let result: GoogleDocNamedRange[] = []; - for (const [fallbackName, collection] of Object.entries( - document.namedRanges as Record, - )) { + for (const [fallbackName, collection] of Object.entries(tab.namedRanges)) { if (!collection || typeof collection !== "object") { throw new Error("Google Docs returned invalid named ranges"); } @@ -1122,9 +1130,9 @@ function googleDocNamedRanges(document: GoogleDocsDocument): GoogleDocNamedRange return result; } -function googleDocNamedRangeIds(document: GoogleDocsDocument, name: string): string[] { +function googleDocNamedRangeIds(tab: GoogleDocsTab, name: string): string[] { let ids = new Set(); - for (let range of googleDocNamedRanges(document)) { + for (let range of googleDocNamedRanges(tab)) { if (range.name === name) ids.add(range.id); } return [...ids]; @@ -1136,15 +1144,15 @@ function googleDocWriteMarkerName(writeId: string): string { } /** - * The write IDs whose content `document` already contains. + * The write IDs whose content `tab` already contains. * * A marker and its content go up in one atomic batch, so a marker naming a write ID proves that * write committed — including the case where its response was lost and its action is still - * pending. Simulating such an action over this document would show its content twice. + * pending. Simulating such an action over this tab would show its content twice. */ -function googleDocCommittedWriteIds(document: GoogleDocsDocument): string[] { +function googleDocCommittedWriteIds(tab: GoogleDocsTab): string[] { let writeIds = new Set(); - for (let range of googleDocNamedRanges(document)) { + for (let range of googleDocNamedRanges(tab)) { if (range.name.startsWith(WRITE_MARKER_PREFIX)) { writeIds.add(range.name.slice(WRITE_MARKER_PREFIX.length)); } @@ -1152,14 +1160,104 @@ function googleDocCommittedWriteIds(document: GoogleDocsDocument): string[] { return [...writeIds]; } -/** Markdown snapshot of `document`, tagged with the writes it already contains. */ -function googleDocSnapshot(document: GoogleDocsDocument): DocSnapshot { +/** One tab's Markdown rendering, tagged with the writes that tab already contains. */ +type GoogleDocTabSnapshot = DocTabSnapshot & { committedWriteIds: string[] }; + +/** A whole document as this gatekeeper caches it: one independent rendering per tab. */ +type GoogleDocSnapshot = { + title: string; + /** Absent unless the caller can edit the document; see `GoogleDocsDocument.revisionId`. */ + revisionId?: string; + tabs: GoogleDocTabSnapshot[]; + /** `Date.now()` at the time of fetch, used for TTL checks. */ + fetchedAt: number; +} + +function googleDocSnapshot(document: GoogleDocsDocument): GoogleDocSnapshot { return { - ...docToMarkdown(document), - committedWriteIds: googleDocCommittedWriteIds(document), + title: document.title, + revisionId: document.revisionId, + tabs: document.tabs.map(tab => ({ + ...docTabToMarkdown(tab), + committedWriteIds: googleDocCommittedWriteIds(tab), + })), + fetchedAt: Date.now(), }; } +/** Accept a cached snapshot only if it predates nothing this code depends on. */ +function isGoogleDocSnapshot(value: unknown): value is GoogleDocSnapshot { + if (!value || typeof value !== "object") return false; + let { tabs, revisionId, fetchedAt } = value as Partial; + return Array.isArray(tabs) && (revisionId === undefined || typeof revisionId === "string") && + typeof fetchedAt === "number" && Number.isFinite(fetchedAt); +} + +/** + * Whether an expired snapshot still describes the current document. + * + * Google withholds `revisionId` from a caller without edit access, leaving no change token, so + * such a document is refetched rather than spending a request on an answer that could never + * confirm the cache. + */ +async function googleDocRevisionUnchanged( + docsApi: GoogleDocsApi, + documentId: string, + cached: GoogleDocSnapshot, +): Promise { + return cached.revisionId !== undefined && + await docsApi.getRevisionId(documentId) === cached.revisionId; +} + +/** + * The tab an operation names, or a failure telling the agent how to name one. + * + * Omission is resolved from the flattened tab list, so a single root with any child counts as + * multi-tab. An unknown ID never falls back to the first tab: the caller meant a specific one. + */ +function resolveGoogleDocTab( + snapshot: GoogleDocSnapshot, + tabId: string | undefined, + operation: "getContent" | "replaceText" | "appendText", +): GoogleDocTabSnapshot { + if (tabId === undefined) { + if (snapshot.tabs.length !== 1) { + throw new Error( + `${operation}: tabId is required for documents with multiple tabs. ` + + `Call listTabs() to choose a tab.`); + } + return snapshot.tabs[0]; + } + let tab = snapshot.tabs.find(candidate => candidate.tabId === tabId); + if (!tab) { + throw new Error( + `${operation}: no tab with ID "${tabId}" exists in this document. ` + + `Call listTabs() to refresh the tab list.`); + } + return tab; +} + +/** The agent-facing view of one tab: identity and position, never content. */ +function googleDocTabMetadata(tab: DocTabSnapshot): GoogleDocTab { + return { + id: tab.tabId, + title: tab.title, + ...tab.parentTabId === undefined ? {} : { parentTabId: tab.parentTabId }, + index: tab.index, + nestingLevel: tab.nestingLevel, + }; +} + +/** + * How a tab is named in approval and observation text. + * + * The ID is included because it is what the write actually targets: titles are user-authored, + * are not required to be unique, and may be empty. + */ +function googleDocTabLabel(tab: DocTabSnapshot): string { + return `"${tab.title}" (${tab.tabId})`; +} + function parseGoogleDocWriteReceipt(value: unknown): GoogleDocWriteReceipt | undefined { if (value === undefined) return undefined; if (!value || typeof value !== "object") { @@ -1175,12 +1273,12 @@ function parseGoogleDocWriteReceipt(value: unknown): GoogleDocWriteReceipt | und type GoogleDocPendingAction = { id: number; action: GoogleDocAction }; +/** One replay of the pending queue, keyed by the state it was computed from. */ type GoogleDocSimulatedContentCache = { - baseRevisionId: string; + baseRevisionId?: string; pendingFingerprint: string; - markdown: string; - pendingActions: GoogleDocAction[]; - computedAt: number; + /** The simulated Markdown of every tab, since one replay covers them all. */ + markdownByTabId: Map; } type GoogleDocSimulationCacheHolder = { @@ -1195,7 +1293,12 @@ function previewMarkdown(markdown: string, maxLength: number): string { return markdown.length > maxLength ? markdown.slice(0, maxLength) + "..." : markdown; } -function findUniqueMarkdown(markdown: string, oldMarkdown: string, operation: string): number { +function findUniqueMarkdown( + markdown: string, + oldMarkdown: string, + operation: string, + tabId: string, +): number { if (oldMarkdown.length === 0) { throw new Error(`${operation}: oldMarkdown must not be empty.`); } @@ -1203,15 +1306,15 @@ function findUniqueMarkdown(markdown: string, oldMarkdown: string, operation: st let index = markdown.indexOf(oldMarkdown); if (index === -1) { throw new Error( - `${operation}: oldMarkdown was not found in the current simulated document. ` + - `Make sure the text exactly matches content returned by getContent().`); + `${operation}: oldMarkdown was not found in the current simulated tab "${tabId}". ` + + `Make sure the text exactly matches content returned by getContent("${tabId}").`); } let secondIndex = markdown.indexOf(oldMarkdown, index + 1); if (secondIndex !== -1) { throw new Error( - `${operation}: oldMarkdown matches multiple locations in the current simulated document. ` + - `Include more surrounding context to make the match unique.`); + `${operation}: oldMarkdown matches multiple locations in the current simulated tab ` + + `"${tabId}". Include more surrounding context to make the match unique.`); } return index; @@ -1219,15 +1322,15 @@ function findUniqueMarkdown(markdown: string, oldMarkdown: string, operation: st function applyMarkdownReplacement( markdown: string, - oldMarkdown: string, - newMarkdown: string, - operation: string, + action: GoogleDocReplaceAction, + tabId: string, ): string { + let { oldMarkdown, newMarkdown } = action; if (oldMarkdown === newMarkdown) { return markdown; } - let index = findUniqueMarkdown(markdown, oldMarkdown, operation); + let index = findUniqueMarkdown(markdown, oldMarkdown, "replaceText", tabId); return markdown.slice(0, index) + newMarkdown + markdown.slice(index + oldMarkdown.length); } @@ -1249,15 +1352,18 @@ function appendMarkdownForSimulation(markdown: string, appendedMarkdown: string) return markdown + "\n\n" + normalizedAppend; } -function applyGoogleDocActionToMarkdown(markdown: string, action: GoogleDocAction): string { +function applyGoogleDocActionToMarkdown( + markdown: string, + action: GoogleDocAction, + tabId: string, +): string { if (action.invalidatedReason) { throw new Error(action.invalidatedReason); } switch (action.type) { case "replaceText": - return applyMarkdownReplacement( - markdown, action.oldMarkdown, action.newMarkdown, "replaceText"); + return applyMarkdownReplacement(markdown, action, tabId); case "appendText": return appendMarkdownForSimulation(markdown, action.markdown); default: @@ -1281,58 +1387,107 @@ function invalidateGoogleDocAction( } } +/** + * The tabs that could hold the write marker of an edit targeting `tabId`. + * + * A marker lives in the tab its write landed in, so one elsewhere belongs to a different write. + * A pre-tab-support edit names no tab, so its marker — and therefore the proof that its write + * already committed — could be in any of them. + */ +function googleDocActionTabs( + tabs: T[], + tabId: string | undefined, +): T[] { + return tabId === undefined ? tabs : tabs.filter(tab => tab.tabId === tabId); +} + +/** + * The tab an edit targets. + * + * A record stored before tabs were addressable names none, but the old code refused to read a + * document with more than one tab, so such a record was approved against a document that had + * exactly one. A document still holding one tab therefore resolves unambiguously; tabs added + * since leave the approved target unknowable. + * + * Exported for coverage: no current write path can produce such a record. + */ +export function googleDocActionTab( + snapshot: GoogleDocSnapshot, + action: GoogleDocAction, +): GoogleDocTabSnapshot { + if (action.tabId === undefined && snapshot.tabs.length !== 1) { + throw new Error( + "Pending Google Doc edit predates tab support and the document has gained tabs since, " + + "so the tab it was approved against is unknown. Reject it and retry on a selected tab."); + } + return resolveGoogleDocTab(snapshot, action.tabId, action.type); +} + +/** + * Replay the pending queue over `snapshot`, invalidating any edit that no longer applies. + * + * Actions are replayed in global approval order, but each one only touches its own tab, so an + * edit to one tab can neither shift nor be shifted by an edit to another. + */ function invalidateUnreplayableGoogleDocActions( pendingActions: PendingActionStore, - baseMarkdown: string, + snapshot: GoogleDocSnapshot, pending: GoogleDocPendingAction[], context: string, -): {markdown: string, pendingActions: GoogleDocAction[]} { - let markdown = baseMarkdown; - let replayedActions: GoogleDocAction[] = []; - for (let i = 0; i < pending.length; i++) { - let action = pending[i].action; - if (action.invalidatedReason) { +): Map { + let markdownByTabId = new Map(snapshot.tabs.map(tab => [tab.tabId, tab.markdown])); + for (let record of pending) { + if (record.action.invalidatedReason) { continue; } try { - markdown = applyGoogleDocActionToMarkdown(markdown, action); + let { tabId } = googleDocActionTab(snapshot, record.action); + markdownByTabId.set( + tabId, + applyGoogleDocActionToMarkdown(markdownByTabId.get(tabId)!, record.action, tabId)); } catch (error) { invalidateGoogleDocAction( pendingActions, - pending[i], + record, `${context}: ${errorMessage(error)} This edit was dropped from the document. ` + `Reject it and retry if it is still needed.`); - continue; } - replayedActions.push(action); } - return {markdown, pendingActions: replayedActions}; + return markdownByTabId; } -function materializeGoogleDocAction(snapshot: DocSnapshot, action: GoogleDocAction): any[] { +/** The batch requests for one edit, together with the tab they are addressed to. */ +function materializeGoogleDocAction( + snapshot: GoogleDocSnapshot, + action: GoogleDocAction, +): { tab: GoogleDocTabSnapshot; requests: any[] } { if (action.invalidatedReason) { throw new Error(action.invalidatedReason); } + let tab = googleDocActionTab(snapshot, action); switch (action.type) { case "replaceText": { let matchStart = findUniqueMarkdown( - snapshot.markdown, action.oldMarkdown, "applyAction(replaceText)"); - let result = computeReplaceOperations( - snapshot.sourceMap, - snapshot.markdown, + tab.markdown, action.oldMarkdown, "applyAction(replaceText)", tab.tabId); + let { requests } = computeReplaceOperations( + tab.sourceMap, + tab.markdown, matchStart, matchStart + action.oldMarkdown.length, - action.newMarkdown); - return result.requests; + action.newMarkdown, + tab.tabId); + return { tab, requests }; } - case "appendText": { - let insertAt = snapshot.bodyEndIndex - 1; - return markdownToDocRequests("\n" + action.markdown, insertAt); - } + case "appendText": + return { + tab, + requests: markdownToDocRequests( + "\n" + action.markdown, tab.bodyEndIndex - 1, tab.tabId), + }; default: action satisfies never; @@ -1390,8 +1545,9 @@ export class GoogleDocGatekeeperImpl let receipt = this.#readDocWriteReceipt(); if (!receipt) return document; - let markerExists = googleDocNamedRanges(document).some( - ({ id }) => id === receipt.markerId, + // The marker ID is exact, but its tab is not recorded, so every tab is searched for it. + let markerExists = document.tabs.some( + tab => googleDocNamedRanges(tab).some(({ id }) => id === receipt.markerId), ); if (!markerExists) { this.#clearDocWriteReceipt(receipt.markerId); @@ -1452,6 +1608,7 @@ export class GoogleDocGatekeeperImpl let pendingActions = new PendingActionStore(this.ctx.storage.kv); return new GoogleDocSessionImpl( api, + new DriveApi(opts => this.#getAccessToken(opts)), this.ctx.props.documentId, approvalQueue.dup(), pendingActions, @@ -1475,10 +1632,11 @@ export class GoogleDocGatekeeperImpl throw new Error(`Unknown pending Google Doc action: ${actionId}`); } let action = pending[pendingIndex].action; + // Left pending, not removed: the overseer keeps its own record when this throws, so removing + // ours would answer the next retry with "unknown action" instead of the reason. Rejecting + // clears both. if (action.invalidatedReason) { - pendingActions.remove(actionId); - this.#simulationCache.current = undefined; - return; + throw new Error(action.invalidatedReason); } let firstPending = pending.find(record => !record.action.invalidatedReason); @@ -1497,34 +1655,37 @@ export class GoogleDocGatekeeperImpl let doc = await api.getDocument(action.documentId); doc = await this.#reconcileDocWriteReceipt(api, doc); let snapshot = googleDocSnapshot(doc); - let markerIds = googleDocNamedRangeIds(doc, writeMarkerName); + let markerIds = [...new Set(googleDocActionTabs(doc.tabs, action.tabId) + .flatMap(tab => googleDocNamedRangeIds(tab, writeMarkerName)))]; if (markerIds.length > 1) { throw new Error(`Google Docs returned multiple write markers for action ${actionId}`); } let [writeMarkerId] = markerIds; if (!writeMarkerId) { - let requests: any[]; + let materialized: { tab: GoogleDocTabSnapshot; requests: any[] }; try { - requests = materializeGoogleDocAction(snapshot, action); + materialized = materializeGoogleDocAction(snapshot, action); } catch (error) { - logger.error("dropping stale Google Doc action during apply", { - event: "google.doc.action.apply.stale.dropped", + // Invalidated, not removed: later edits stop waiting behind it, and approving it again + // repeats the reason rather than reporting success for a write that never happened. + logger.error("Google Doc action cannot be applied", { + event: "google.doc.action.apply.unapplyable", actionId, error, }); - pendingActions.remove(actionId); + invalidateGoogleDocAction( + pendingActions, + pending[pendingIndex], + `Pending Google Doc edit could not be applied: ${errorMessage(error)}`); this.#simulationCache.current = undefined; await this.ctx.storage.put(DOC_SNAPSHOT_KEY, snapshot); - invalidateUnreplayableGoogleDocActions( - pendingActions, - snapshot.markdown, - pending.slice(pendingIndex + 1), - `Pending Google Doc edits could not be replayed after edit ${actionId} was dropped`); - return; + throw error; } + let { tab, requests } = materialized; if (requests.length > 0) { let result = await api.batchUpdate(action.documentId, requests, snapshot.revisionId, { name: writeMarkerName, - rangeStart: snapshot.bodyEndIndex - 1, + rangeStart: tab.bodyEndIndex - 1, + tabId: tab.tabId, }); if (!result.writeMarkerId) { throw new Error(`Google Docs did not return a write marker for action ${actionId}`); @@ -1555,7 +1716,7 @@ export class GoogleDocGatekeeperImpl await this.ctx.storage.put(DOC_SNAPSHOT_KEY, refreshedSnapshot); invalidateUnreplayableGoogleDocActions( pendingActions, - refreshedSnapshot.markdown, + refreshedSnapshot, pending.slice(pendingIndex + 1), `Pending Google Doc edits could not be replayed after edit ${actionId} was applied`); } catch (error) { @@ -1612,6 +1773,7 @@ export class GoogleDocGatekeeperImpl @validateRpc() class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { #docsApi: GoogleDocsApi; + #driveApi: DriveApi; #documentId: string; #approvalQueue: RpcStub; #pendingActions: PendingActionStore; @@ -1620,6 +1782,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { constructor( docsApi: GoogleDocsApi, + driveApi: DriveApi, documentId: string, approvalQueue: RpcStub, pendingActions: PendingActionStore, @@ -1628,6 +1791,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { ) { super(); this.#docsApi = docsApi; + this.#driveApi = driveApi; this.#documentId = documentId; this.#approvalQueue = approvalQueue; this.#pendingActions = pendingActions; @@ -1635,21 +1799,17 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { this.#simulationCache = simulationCache; } - async #getSnapshot(forceRefresh?: boolean): Promise { - if (!forceRefresh) { - let cached = await this.#storage.get(DOC_SNAPSHOT_KEY); - if (cached) { - let age = Date.now() - cached.fetchedAt; - if (age < 10_000) { - return cached; - } - // TTL expired — check if document has changed. - let currentRevisionId = await this.#docsApi.getRevisionId(this.#documentId); - if (currentRevisionId === cached.revisionId) { - cached.fetchedAt = Date.now(); - await this.#storage.put(DOC_SNAPSHOT_KEY, cached); - return cached; - } + async #getSnapshot(): Promise { + // A snapshot written before tabs existed has no tab list and is simply replaced. + let cached = await this.#storage.get(DOC_SNAPSHOT_KEY); + if (isGoogleDocSnapshot(cached)) { + if (Date.now() - cached.fetchedAt < DOC_SNAPSHOT_TTL_MS) { + return cached; + } + if (await googleDocRevisionUnchanged(this.#docsApi, this.#documentId, cached)) { + cached.fetchedAt = Date.now(); + await this.#storage.put(DOC_SNAPSHOT_KEY, cached); + return cached; } } @@ -1660,44 +1820,52 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { return snapshot; } - async #getSimulatedContent(): Promise<{ - snapshot: DocSnapshot, + /** + * The selected tab's content with every pending edit replayed over it. + * + * The selector is resolved before anything is replayed or cached, so naming a tab that does not + * exist cannot disturb the pending queue. + */ + async #getSimulatedContent( + tabId: string | undefined, + operation: "getContent" | "replaceText" | "appendText", + ): Promise<{ + snapshot: GoogleDocSnapshot, + tab: GoogleDocTabSnapshot, markdown: string, - pendingActions: GoogleDocAction[], }> { let snapshot = await this.#getSnapshot(); + let tab = resolveGoogleDocTab(snapshot, tabId, operation); let pending = this.#pendingActions.list(); let pendingFingerprint = googleDocPendingFingerprint(pending); let cached = this.#simulationCache.current; - if (cached && cached.baseRevisionId === snapshot.revisionId && + // An unknown revision cannot be shown to match, so the replay is recomputed. + if (cached && cached.baseRevisionId !== undefined && + cached.baseRevisionId === snapshot.revisionId && cached.pendingFingerprint === pendingFingerprint) { - return { - snapshot, - markdown: cached.markdown, - pendingActions: cached.pendingActions, - }; + return {snapshot, tab, markdown: cached.markdownByTabId.get(tab.tabId) ?? tab.markdown}; } - // An edit whose marker is already in the document committed even though its response never + // An edit whose marker is already in its tab committed even though its response never // arrived, so this snapshot contains it. Replaying it would show that content twice; the // action stays pending, and applyAction() settles it from the same marker. - let committed = new Set(snapshot.committedWriteIds); - let replayable = pending.filter( - ({action}) => action.writeId === undefined || !committed.has(action.writeId)); + let replayable = pending.filter(({action}) => { + let {writeId} = action; + return writeId === undefined || !googleDocActionTabs(snapshot.tabs, action.tabId) + .some(tab => tab.committedWriteIds.includes(writeId)); + }); - let {markdown, pendingActions} = invalidateUnreplayableGoogleDocActions( + let markdownByTabId = invalidateUnreplayableGoogleDocActions( this.#pendingActions, - snapshot.markdown, + snapshot, replayable, "Pending Google Doc edit could not be replayed against the current document"); this.#simulationCache.current = { baseRevisionId: snapshot.revisionId, pendingFingerprint: googleDocPendingFingerprint(this.#pendingActions.list()), - markdown, - pendingActions, - computedAt: Date.now(), + markdownByTabId, }; - return {snapshot, markdown, pendingActions}; + return {snapshot, tab, markdown: markdownByTabId.get(tab.tabId) ?? tab.markdown}; } /** @@ -1705,12 +1873,15 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { * * Google Docs exposes no modification time, so the moment this binding first saw the current * revision stands in for it and is reused for as long as that revision holds — reading a - * document must not make it look freshly edited. Pending edits still move it forward, since + * document must not make it look freshly edited. Without edit access there is no revision to + * date, and Drive's own timestamp is used instead. Pending edits still move it forward, since * `getContent()` already shows them. */ async getMetadata(): Promise { let metadata = await this.#docsApi.getDocumentMetadata(this.#documentId); - let revisedAt = this.#observeDocRevision(metadata.revisionId); + let revisedAt = metadata.revisionId === undefined + ? await this.#modifiedWithoutRevision() + : this.#observeDocRevision(metadata.revisionId); let pendingActions = this.#pendingActions.list() .map(({action}) => action) .filter(action => !action.invalidatedReason); @@ -1728,17 +1899,44 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { }; } + /** + * The modification time of a document Google reports no revision for. + * + * Without edit access there is no revision to date, so Drive is asked instead. An account + * connected before per-resource grants may not hold the picker's metadata scope, leaving no + * signal at all; the first observation then stands rather than every read looking like an edit. + */ + async #modifiedWithoutRevision(): Promise { + try { + return driveModifiedTime(await this.#driveApi.getFile(this.#documentId)).valueOf(); + } catch (error) { + // Only a refused grant means no signal will ever arrive. A quota 403 (also 403), an outage + // or a malformed body are transient or fixable, and dating the document from one would + // report a changed document as unchanged for as long as Drive stays unhealthy. + let refusedGrant = error instanceof DriveApiRequestError && error.status === 403 && + !error.isQuotaExceeded; + if (!refusedGrant) throw error; + logger.warn("no Drive grant to date a Google Doc that has no revision", { + event: "google.doc.metadata.drive.ungranted", error, + }); + return this.#observeDocRevision(undefined); + } + } + /** * When this binding first saw `revisionId`, recording it if the revision is new. * * An unreadable record is re-observed rather than rejected: it only dates a revision, so the - * worst a lost record costs is one timestamp that moves when the document did not. + * worst a lost record costs is one timestamp that moves when the document did not. Two absent + * revisions count as the same: a document that offers no change token must not look edited by + * every read, which is the opposite of what a cache needs from the same comparison. */ - #observeDocRevision(revisionId: string): number { + #observeDocRevision(revisionId?: string): number { let stored = this.#storage.kv.get(DOC_METADATA_REVISION_KEY); if (stored && typeof stored === "object") { let { revisionId: seen, observedAt } = stored as Partial; - if (seen === revisionId && typeof observedAt === "number" && Number.isFinite(observedAt)) { + if (seen === revisionId && + typeof observedAt === "number" && Number.isFinite(observedAt)) { return observedAt; } } @@ -1748,28 +1946,61 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { return observedAt; } - async getContent(): Promise { - let {markdown} = await this.#getSimulatedContent(); + async listTabs(): Promise { + let snapshot = await this.#getSnapshot(); await this.#approvalQueue.authorizeObservation({ - title: "Read Google Doc content", - description: "Read the full simulated content of the document as Markdown.", + title: "List Google Doc tabs", + description: "Read the document's tab names and hierarchy.", }); - return markdown; + return snapshot.tabs.map(googleDocTabMetadata); } - async replaceText(oldMarkdown: string, newMarkdown: string): Promise { + async getContent(tabId?: string): Promise { + let selected; + try { + selected = await this.#getSimulatedContent(tabId, "getContent"); + } catch (error) { + // The error says whether that tab exists, so the attempt discloses something too. + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the content of one tab of the document.", + }); + throw error; + } + + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: + `Read the full simulated content of tab ${googleDocTabLabel(selected.tab)} as Markdown.`, + }); + return selected.markdown; + } + + async replaceText(oldMarkdown: string, newMarkdown: string, tabId?: string): Promise { if (oldMarkdown === newMarkdown) { return; } - let {snapshot, markdown} = await this.#getSimulatedContent(); - findUniqueMarkdown(markdown, oldMarkdown, "replaceText"); + let selected; + try { + selected = await this.#getSimulatedContent(tabId, "replaceText"); + findUniqueMarkdown(selected.markdown, oldMarkdown, "replaceText", selected.tab.tabId); + } catch (error) { + // The error says whether that tab, or that text, exists. + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the content of one tab of the document.", + }); + throw error; + } + let {snapshot, tab} = selected; let action: GoogleDocAction = { type: "replaceText", documentId: this.#documentId, + tabId: tab.tabId, submittedAt: Date.now(), baseRevisionId: snapshot.revisionId, writeId: crypto.randomUUID(), @@ -1786,7 +2017,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { await this.#approvalQueue.submitAction(actionId, { title: "Edit Google Doc", description: - `Replace text in the document.\n\n` + + `Replace text in tab ${googleDocTabLabel(tab)}.\n\n` + `**Old:** ${oldPreview}\n\n` + `**New:** ${newPreview}`, implementsRevert: false, @@ -1801,12 +2032,24 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { } } - async appendText(markdown: string): Promise { - let {snapshot} = await this.#getSimulatedContent(); + async appendText(markdown: string, tabId?: string): Promise { + let selected; + try { + selected = await this.#getSimulatedContent(tabId, "appendText"); + } catch (error) { + // The error says whether that tab exists, so the attempt discloses something too. + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the content of one tab of the document.", + }); + throw error; + } + let {snapshot, tab} = selected; let action: GoogleDocAction = { type: "appendText", documentId: this.#documentId, + tabId: tab.tabId, submittedAt: Date.now(), baseRevisionId: snapshot.revisionId, writeId: crypto.randomUUID(), @@ -1820,7 +2063,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { try { await this.#approvalQueue.submitAction(actionId, { title: "Append to Google Doc", - description: `Append content to the end of the document:\n\n${preview}`, + description: `Append content to the end of tab ${googleDocTabLabel(tab)}:\n\n${preview}`, implementsRevert: false, // Same "editDocument" tag as replaceText actionKind: EDIT_DOCUMENT_ACTION, @@ -2606,6 +2849,8 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession #driveApi: DriveApi; #documentId: string; #approvalQueue: RpcStub; + /** The most recent snapshot request. Chaining onto it serializes concurrent reads. */ + #snapshot?: Promise; constructor( docsApi: GoogleDocsApi, @@ -2626,10 +2871,7 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession async getMetadata(): Promise { let file = await this.#driveApi.getFile(this.#documentId); - let lastModified = new Date(file.modifiedTime ?? ""); - if (Number.isNaN(lastModified.valueOf())) { - throw new Error("Google Drive returned an invalid modifiedTime"); - } + let lastModified = driveModifiedTime(file); await this.#approvalQueue.authorizeObservation({ title: "Read Google Doc metadata", description: "Read the current title and modification time of the Drive document.", @@ -2637,13 +2879,53 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession return { title: file.name, lastModified }; } - async getContent(): Promise { - let snapshot = docToMarkdown(await this.#docsApi.getDocument(this.#documentId)); + // Each call chains onto the previous request, so concurrent reads share one fetch instead of + // racing to overwrite each other with whichever response lands last. + #getSnapshot(): Promise { + return this.#snapshot = this.#nextSnapshot(this.#snapshot); + } + + /** Reuse one revision for the TTL, then confirm it is still current before reusing it again. */ + async #nextSnapshot(pending?: Promise): Promise { + let cached = await pending?.catch(() => undefined); + if (cached) { + if (Date.now() - cached.fetchedAt < DOC_SNAPSHOT_TTL_MS) return cached; + if (await googleDocRevisionUnchanged(this.#docsApi, this.#documentId, cached)) { + cached.fetchedAt = Date.now(); + return cached; + } + } + return googleDocSnapshot(await this.#docsApi.getDocument(this.#documentId)); + } + + async listTabs(): Promise { + let snapshot = await this.#getSnapshot(); + await this.#approvalQueue.authorizeObservation({ + title: "List Google Doc tabs", + description: "Read the document's tab names and hierarchy.", + }); + return snapshot.tabs.map(googleDocTabMetadata); + } + + async getContent(tabId?: string): Promise { + let snapshot = await this.#getSnapshot(); + let tab: GoogleDocTabSnapshot; + try { + tab = resolveGoogleDocTab(snapshot, tabId, "getContent"); + } catch (error) { + // The selector error says whether a tab exists, so the attempt discloses something too. + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the content of one tab of the document.", + }); + throw error; + } + await this.#approvalQueue.authorizeObservation({ title: "Read Google Doc content", - description: "Read the current document body as Markdown.", + description: `Read the current content of tab ${googleDocTabLabel(tab)} as Markdown.`, }); - return snapshot.markdown; + return tab.markdown; } } diff --git a/packages/gatekeeper-google/src/markdown-converter.ts b/packages/gatekeeper-google/src/markdown-converter.ts index a6089ada39..485dece1d7 100644 --- a/packages/gatekeeper-google/src/markdown-converter.ts +++ b/packages/gatekeeper-google/src/markdown-converter.ts @@ -2,32 +2,34 @@ // with source mapping to allow Markdown-level edits to be translated back // to Google Docs batchUpdate operations. -import type { GoogleDocsDocument, Paragraph } from "./docs-api"; +import type { GoogleDocsTab, Paragraph } from "./docs-api"; // --------------------------------------------------------------------------- // Source map types // --------------------------------------------------------------------------- -/** A complete snapshot of a document, cached in the gatekeeper's DO storage. */ -export type DocSnapshot = { - /** Document title. */ +/** + * The Markdown rendering of one document tab, with the map back to that tab's indices. + * + * Every tab body has its own index space, so a snapshot is only ever valid for the tab it was + * built from — Markdown, source map and end index all restart per tab. + */ +export type DocTabSnapshot = { + /** The tab this rendering came from; every write derived from it must carry this ID. */ + tabId: string; + /** Tab name, as shown in the Docs tab list. */ title: string; - /** The revisionId at the time the document was fetched. */ - revisionId: string; - /** The Markdown rendering of the document content. */ + /** The containing tab, absent for a top-level tab. */ + parentTabId?: string; + /** Position among the tabs sharing this parent. */ + index: number; + /** Depth in the tab tree; 0 for a top-level tab. */ + nestingLevel: number; + /** The Markdown rendering of this tab's content. */ markdown: string; - /** Maps Markdown positions back to Google Docs character indices. */ + /** Maps Markdown positions back to this tab's Google Docs character indices. */ sourceMap: SourceMap; - /** `Date.now()` at the time of fetch, used for TTL checks. */ - fetchedAt: number; - /** - * Write IDs whose marked batch is already present in this snapshot's document. - * - * Filled in by the gatekeeper, which owns the write-marker convention; absent on snapshots - * persisted before it existed, which self-correct on the next fetch. - */ - committedWriteIds?: string[]; - /** The endIndex of the last structural element in the document body. */ + /** The endIndex of the last structural element in this tab's body. */ bodyEndIndex: number; } @@ -61,13 +63,13 @@ export type Segment = // Google Docs → Markdown // --------------------------------------------------------------------------- -/** Convert a Google Docs document to Markdown with source map. */ -export function docToMarkdown(document: GoogleDocsDocument): DocSnapshot { +/** Convert one document tab to Markdown with a source map into that tab's index space. */ +export function docTabToMarkdown(tab: GoogleDocsTab): DocTabSnapshot { let md = ""; let blocks: BlockMapping[] = []; let lastWasListItem = false; - let elements = document.body.content; + let elements = tab.body.content; let bodyEndIndex = elements.length > 0 ? elements[elements.length - 1].endIndex : 0; for (let elem of elements) { @@ -89,7 +91,7 @@ export function docToMarkdown(document: GoogleDocsDocument): DocSnapshot { if (para.bullet) { nestingLevel = para.bullet.nestingLevel; - let list = document.lists[para.bullet.listId]; + let list = tab.lists[para.bullet.listId]; if (list) { let level = list.listProperties.nestingLevels[nestingLevel]; if (level) { @@ -137,11 +139,13 @@ export function docToMarkdown(document: GoogleDocsDocument): DocSnapshot { } return { - title: document.title, - revisionId: document.revisionId, + tabId: tab.tabId, + title: tab.title, + ...tab.parentTabId === undefined ? {} : { parentTabId: tab.parentTabId }, + index: tab.index, + nestingLevel: tab.nestingLevel, markdown: md, sourceMap: { blocks }, - fetchedAt: Date.now(), bodyEndIndex, }; } @@ -562,12 +566,19 @@ function parseInlineFormatting(text: string): { plainText: string; spans: Format } /** - * Convert a Markdown string into Google Docs batchUpdate request objects - * that insert the content at the given document index. + * Convert a Markdown string into Google Docs batchUpdate request objects that insert the content + * at the given index inside tab `tabId`. + * + * Every emitted coordinate names that tab: tab bodies have independent index spaces, so an + * unqualified index lands in whichever tab Google picks. * * Returns requests in the order they should appear in the batchUpdate array. */ -export function markdownToDocRequests(markdown: string, insertAt: number): any[] { +export function markdownToDocRequests( + markdown: string, + insertAt: number, + tabId: string, +): any[] { let blocks = parseMarkdown(markdown); if (blocks.length === 0) return []; @@ -580,14 +591,14 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] if (fullText.length === 0) { // `parseMarkdown()` treats whitespace-only input as blank Markdown blocks, but replacements // can legitimately insert whitespace inside existing text, e.g. splitting a word in two. - return [{ insertText: { location: { index: insertAt }, text: markdown } }]; + return [{ insertText: { location: { index: insertAt, tabId }, text: markdown } }]; } // Insert the full text in one go. This is more efficient and avoids // index-shifting complexity from multiple insertions. requests.push({ insertText: { - location: { index: insertAt }, + location: { index: insertAt, tabId }, text: fullText, }, }); @@ -603,7 +614,7 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] let styleType = `HEADING_${block.headingLevel}`; requests.push({ updateParagraphStyle: { - range: { startIndex: blockStart, endIndex: blockEnd + 1 }, + range: { startIndex: blockStart, endIndex: blockEnd + 1, tabId }, paragraphStyle: { namedStyleType: styleType }, fields: "namedStyleType", }, @@ -617,7 +628,7 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] : "BULLET_DISC_CIRCLE_SQUARE"; requests.push({ createParagraphBullets: { - range: { startIndex: blockStart, endIndex: blockEnd + 1 }, + range: { startIndex: blockStart, endIndex: blockEnd + 1, tabId }, bulletPreset: preset, }, }); @@ -637,7 +648,7 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] if (span.strikethrough) { textStyle.strikethrough = true; fields.push("strikethrough"); } requests.push({ updateTextStyle: { - range: { startIndex: spanStart, endIndex: spanEnd }, + range: { startIndex: spanStart, endIndex: spanEnd, tabId }, textStyle, fields: fields.join(","), }, @@ -647,7 +658,7 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] if (span.link) { requests.push({ updateTextStyle: { - range: { startIndex: spanStart, endIndex: spanEnd }, + range: { startIndex: spanStart, endIndex: spanEnd, tabId }, textStyle: { link: { url: span.link } }, fields: "link", }, @@ -667,8 +678,8 @@ export function markdownToDocRequests(markdown: string, insertAt: number): any[] // --------------------------------------------------------------------------- /** - * Given a match range in the Markdown snapshot, compute the batchUpdate - * operations to replace that range with new Markdown content. + * Given a match range in one tab's Markdown snapshot, compute the batchUpdate operations to + * replace that range with new Markdown content inside tab `tabId`. * * Automatically trims unchanged leading/trailing text to minimize the edit. */ @@ -678,6 +689,7 @@ export function computeReplaceOperations( matchStart: number, matchEnd: number, newMarkdown: string, + tabId: string, ): { requests: any[]; trimmedOld: string; trimmedNew: string } { let oldText = markdown.slice(matchStart, matchEnd); @@ -721,14 +733,14 @@ export function computeReplaceOperations( if (docRange.start < docRange.end) { requests.push({ deleteContentRange: { - range: { startIndex: docRange.start, endIndex: docRange.end }, + range: { startIndex: docRange.start, endIndex: docRange.end, tabId }, }, }); } // Insert the new content (if any). if (trimmedNew.length > 0) { - let insertRequests = markdownToDocRequests(trimmedNew, docRange.start); + let insertRequests = markdownToDocRequests(trimmedNew, docRange.start, tabId); requests.push(...insertRequests); } diff --git a/packages/gatekeeper-google/src/type-bundle.ts b/packages/gatekeeper-google/src/type-bundle.ts index 200c17bf80..7cedd08e8b 100644 --- a/packages/gatekeeper-google/src/type-bundle.ts +++ b/packages/gatekeeper-google/src/type-bundle.ts @@ -1,7 +1,7 @@ /** Module-only prefix of the Google Docs declaration. */ export const DOCS_TYPES_MODULE_PREFIX = 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + - 'export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types";\n\n'; + 'export type { DocMetadata, GoogleDocReadSession, GoogleDocTab } from "./docs-read-types";\n\n'; /** Module-only prefix of the Google Drive declaration. */ export const DRIVE_TYPES_MODULE_PREFIX = From 81f6a0e6dd31524f2b842ce7eae4294d2928963a Mon Sep 17 00:00:00 2001 From: Max Peterson <64494795+maxwellpeterson@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:20:09 -0700 Subject: [PATCH 03/15] add xlsx export suppport to workspace-sheets blueprint (#433) --- .../__tests__/format-blueprints.test.ts | 3 + .../__tests__/workspace-sheets-xlsx.test.ts | 788 ++++++++++++++++++ .../workspace-sheets/files/README.md | 49 +- .../workspace-sheets/files/server.js | 119 ++- .../workspace-sheets/files/xlsx.js | 715 ++++++++++++++++ .../workspace-sheets/files/zip.js | 150 ++++ 6 files changed, 1774 insertions(+), 50 deletions(-) create mode 100644 packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts create mode 100644 packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js create mode 100644 packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js diff --git a/packages/workshop-backend/__tests__/format-blueprints.test.ts b/packages/workshop-backend/__tests__/format-blueprints.test.ts index adf7d0e0a8..26b074d632 100644 --- a/packages/workshop-backend/__tests__/format-blueprints.test.ts +++ b/packages/workshop-backend/__tests__/format-blueprints.test.ts @@ -109,6 +109,9 @@ describe("bundled format blueprints", () => { ], "format.spreadsheet": [ 'const CSV_FORMAT_PREFIX = "csv:"', + 'id: "xlsx"', + 'label: "Excel Workbook"', + 'contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"', 'mode: "server"', 'contentType: "text/csv"', ], diff --git a/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts b/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts new file mode 100644 index 0000000000..6cf142d685 --- /dev/null +++ b/packages/workshop-backend/__tests__/workspace-sheets-xlsx.test.ts @@ -0,0 +1,788 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExportHandler, Gadget } from "../format-blueprints/workspace-sheets/files/server.js"; +import { workbookToXlsx } from "../format-blueprints/workspace-sheets/files/xlsx.js"; +import { createZip, crc32 } from "../format-blueprints/workspace-sheets/files/zip.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +type ZipEntry = { + bytes: Uint8Array; + compressedSize: number; + crc: number; + flags: number; + localOffset: number; + method: number; + uncompressedSize: number; +}; + +async function streamBytes(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function uint16(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true); +} + +function uint32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); +} + +async function inflate(bytes: Uint8Array): Promise { + const input = new Response(bytes).body!.pipeThrough(new DecompressionStream("deflate-raw")); + return new Uint8Array(await new Response(input).arrayBuffer()); +} + +async function readZip(stream: ReadableStream) { + const archive = await streamBytes(stream); + const eocdOffset = archive.byteLength - 22; + expect(uint32(archive, eocdOffset)).toBe(0x06054b50); + expect(uint16(archive, eocdOffset + 4)).toBe(0); + expect(uint16(archive, eocdOffset + 6)).toBe(0); + const entryCount = uint16(archive, eocdOffset + 10); + expect(uint16(archive, eocdOffset + 8)).toBe(entryCount); + const centralSize = uint32(archive, eocdOffset + 12); + const centralOffset = uint32(archive, eocdOffset + 16); + expect(centralOffset + centralSize).toBe(eocdOffset); + + const entries = new Map(); + let offset = centralOffset; + for (let i = 0; i < entryCount; ++i) { + expect(uint32(archive, offset)).toBe(0x02014b50); + const flags = uint16(archive, offset + 8); + const method = uint16(archive, offset + 10); + const crc = uint32(archive, offset + 16); + const compressedSize = uint32(archive, offset + 20); + const uncompressedSize = uint32(archive, offset + 24); + const nameLength = uint16(archive, offset + 28); + const extraLength = uint16(archive, offset + 30); + const commentLength = uint16(archive, offset + 32); + const localOffset = uint32(archive, offset + 42); + const name = decoder.decode(archive.subarray(offset + 46, offset + 46 + nameLength)); + + expect(uint32(archive, localOffset)).toBe(0x04034b50); + expect(uint16(archive, localOffset + 6)).toBe(flags); + expect(uint16(archive, localOffset + 8)).toBe(method); + expect(uint16(archive, localOffset + 10)).toBe(0); + expect(uint16(archive, localOffset + 12)).toBe(33); + expect(uint32(archive, localOffset + 14)).toBe(0); + expect(uint32(archive, localOffset + 18)).toBe(0); + expect(uint32(archive, localOffset + 22)).toBe(0); + const localNameLength = uint16(archive, localOffset + 26); + const localExtraLength = uint16(archive, localOffset + 28); + expect(decoder.decode(archive.subarray(localOffset + 30, localOffset + 30 + localNameLength))).toBe(name); + + const dataOffset = localOffset + 30 + localNameLength + localExtraLength; + const compressed = archive.subarray(dataOffset, dataOffset + compressedSize); + const descriptorOffset = dataOffset + compressedSize; + expect(uint32(archive, descriptorOffset)).toBe(0x08074b50); + expect(uint32(archive, descriptorOffset + 4)).toBe(crc); + expect(uint32(archive, descriptorOffset + 8)).toBe(compressedSize); + expect(uint32(archive, descriptorOffset + 12)).toBe(uncompressedSize); + + const bytes = await inflate(compressed); + expect(bytes.byteLength).toBe(uncompressedSize); + expect(crc32(bytes)).toBe(crc); + entries.set(name, {bytes, compressedSize, crc, flags, localOffset, method, uncompressedSize}); + offset += 46 + nameLength + extraLength + commentLength; + } + expect(offset).toBe(eocdOffset); + return {archive, entries}; +} + +function text(entries: Map, name: string): string { + const entry = entries.get(name); + expect(entry, name).toBeDefined(); + return decoder.decode(entry!.bytes); +} + +function cell(value: unknown, fmt: Record | null = null) { + return {value, fmt, version: 1}; +} + +function sheet(name: string, extra: Record = {}) { + return { + id: name, + name, + rows: 100, + cols: 26, + colWidths: {}, + rowHeights: {}, + frozenRows: 0, + frozenCols: 0, + ...extra, + }; +} + +function cellXml(xml: string, reference: string): string { + const match = new RegExp(`]*?/>|]*>[\\s\\S]*?`).exec(xml); + expect(match, reference).not.toBeNull(); + return match![0]; +} + +function styleId(xml: string, reference: string): string | undefined { + return / s="(\d+)"/.exec(cellXml(xml, reference))?.[1]; +} + +function handler(): ExportHandler { + return Object.create(ExportHandler.prototype) as ExportHandler; +} + +// A Gadget over in-memory storage, for exercising the mutation queue without a Durable Object. +function inMemoryGadget(subscribers: Map = new Map()) { + const stored = new Map([ + ["meta", {revision: 0, title: "Test", sheetOrder: ["sheet"], sheets: {sheet: sheet("Sheet")}, lastModified: 0}], + ["cells:sheet", {}], + ]); + return Object.assign(Object.create(Gadget.prototype), { + ctx: { + storage: { + get: async (key: string) => stored.get(key), + put: async (key: string, value: unknown) => { stored.set(key, value); }, + delete: async (key: string) => stored.delete(key), + }, + }, + mutationQueue: Promise.resolve(), + subscribers, + }) as Gadget; +} + +function setCell(ref: string, value: string, baseVersion = 0) { + return {senderId: "test", cellOps: [{sheetId: "sheet", ref, value, fmt: null, baseVersion}]}; +} + +describe("streaming ZIP32", () => { + it("calculates CRC32 and emits valid descriptor-based deflate entries", async () => { + expect(crc32(encoder.encode("123456789"))).toBe(0xcbf43926); + const chunks = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("streamed ")); + controller.enqueue(encoder.encode("content")); + controller.close(); + }, + }); + + const {entries} = await readZip(createZip([ + {name: "plain.txt", data: "hello"}, + {name: "nested/utf8-\u2603.txt", data: chunks}, + ])); + + expect([...entries.keys()]).toEqual(["plain.txt", "nested/utf8-\u2603.txt"]); + expect(text(entries, "plain.txt")).toBe("hello"); + expect(text(entries, "nested/utf8-\u2603.txt")).toBe("streamed content"); + for (const entry of entries.values()) { + expect(entry.flags).toBe(0x0808); + expect(entry.method).toBe(8); + expect(entry.compressedSize).toBeGreaterThan(0); + } + }); +}); + +describe("Workspace Sheets XLSX", () => { + it("emits the required OOXML package and a valid blank worksheet for malformed state", async () => { + for (const document of [{}, {sheetOrder: ["empty"], sheets: {empty: sheet("")}, cells: {empty: {}}}]) { + const {entries} = await readZip(workbookToXlsx(document)); + expect([...entries.keys()]).toEqual([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/worksheets/sheet1.xml", + ]); + expect(text(entries, "[Content_Types].xml")).toContain("spreadsheetml.sheet.main+xml"); + expect(text(entries, "_rels/.rels")).toContain('Target="xl/workbook.xml"'); + expect(text(entries, "xl/_rels/workbook.xml.rels")).toContain('Target="worksheets/sheet1.xml"'); + expect(text(entries, "xl/_rels/workbook.xml.rels")).toContain('Target="styles.xml"'); + expect(text(entries, "xl/workbook.xml")).toContain(''); + expect(text(entries, "xl/worksheets/sheet1.xml")).toContain(""); + } + }); + + it("preserves sheet order, normalizes names, and rewrites recognized formula references", async () => { + const longName = "This worksheet name is substantially longer than Excel permits"; + const document = { + sheetOrder: ["a", "a", "b", "c", "d", "e", "f", "g", "h", "i"], + sheets: { + a: sheet("Sales/Data"), + b: sheet("sales_data"), + c: sheet("Sales/Data"), + d: sheet(longName), + e: sheet(" "), + f: sheet("History"), + g: sheet("O'Brien"), + h: sheet("[Book.xlsx]Data"), + i: sheet("Q[1]"), + }, + cells: { + a: {A1: cell("1")}, + b: {A1: cell("2")}, + c: { + A1: cell('=\'Sales/Data\'!A1+sales_data!$A$1+"Sales/Data!A1"+History!A1+\'O\'\'Brien\'!A1'), + A2: cell("='[Book.xlsx]Data'!A1+[Other.xlsx]'Sales/Data'!A1+'Q[1]'!A1+" + + "'Sales/Data':'History'!A1+'Missing'!A1+'Sales/Data'!NOPE+foo'Sales/Data'!A1"), + }, + d: {}, + e: {}, + f: {A1: cell("3")}, + g: {A1: cell("4")}, + h: {A1: cell("5")}, + i: {A1: cell("6")}, + }, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const workbook = text(entries, "xl/workbook.xml"); + const names = [...workbook.matchAll(/ match[1]); + expect(names).toEqual([ + "Sales_Data", + "sales_data (2)", + "Sales_Data (3)", + longName.slice(0, 31), + "Sheet", + "History_", + "O'Brien", + "_Book.xlsx_Data", + "Q_1_", + ]); + const worksheet = text(entries, "xl/worksheets/sheet3.xml"); + const formulaCell = cellXml(worksheet, "A1"); + expect(formulaCell).toContain('\'Sales_Data\'!A1+\'sales_data (2)\'!$A$1+"Sales/Data!A1"+\'History_\'!A1+\'O\'\'Brien\'!A1'); + expect(formulaCell).not.toContain(""); + expect(cellXml(worksheet, "A2")).toContain( + "'_Book.xlsx_Data'!A1+[Other.xlsx]'Sales/Data'!A1+'Q_1_'!A1+" + + "'Sales/Data':'History'!A1+'Missing'!A1+'Sales/Data'!NOPE+foo'Sales/Data'!A1"); + expect(workbook).toContain(''); + }); + + it("exports many maximum-length formulas with unterminated quoted names as text", async () => { + const formula = "'".repeat(8191); + const cells = Object.fromEntries(Array.from({length: 64}, (_, index) => [ + `A${index + 1}`, + cell("=" + formula), + ])); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["formulas"], + sheets: {formulas: sheet("Formulas", {rows: 64, cols: 1})}, + cells: {formulas: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + + expect(worksheet.split(`=${formula}`)).toHaveLength(65); + }); + + it("does not lengthen maximum-size formulas when unquoted sheet names are unchanged", async () => { + const value = "=data!A1" + "+0".repeat(4092); + expect(value).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data", "formulas"], + sheets: {data: sheet("Data"), formulas: sheet("Formulas")}, + cells: {data: {A1: cell("1")}, formulas: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet2.xml"), "A1")) + .toContain(`${value.slice(1)}`); + }); + + it("exports formulas as text when required rewrites exceed Excel's length limit", async () => { + const renamed = "=A_B!A10" + "+0".repeat(4092); + const future = "=IFS(TRUE,1)" + "+0".repeat(4090); + expect(renamed).toHaveLength(8192); + expect(future).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["invalid", "collision", "formulas"], + sheets: { + invalid: sheet("A/B"), + collision: sheet("A_B"), + formulas: sheet("Formulas"), + }, + cells: {invalid: {}, collision: {}, formulas: {A1: cell(renamed), A2: cell(future)}}, + })); + const worksheet = text(entries, "xl/worksheets/sheet3.xml"); + + expect(cellXml(worksheet, "A1")) + .toBe(`${renamed}`); + expect(cellXml(worksheet, "A2")) + .toBe(`${future}`); + }); + + it("prefixes OOXML future functions without changing strings, sheet references, or existing prefixes", async () => { + const calls = [ + "IFS(TRUE,1)", "IFNA(A1,0)", "XOR(TRUE,FALSE)", "SWITCH(1,1,1)", + 'CONCAT("a","b")', 'TEXTJOIN(",",TRUE,A1)', "UNICHAR(65)", "UNICODE(A1)", "DAYS(2,1)", + ]; + const suffix = '+"CONCAT("+CONCAT!A1+_xlfn.CONCAT(A1)+Table1[IFS(A1)]+Table1[CONCAT!A1]'; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["concat", "formulas"], + sheets: {concat: sheet("CONCAT"), formulas: sheet("Formulas")}, + cells: {concat: {A1: cell("value")}, formulas: { + A1: cell("=" + calls.join("+") + suffix), + A2: cell("=SUM (1,2)+ifs\t(TRUE,1)+\"SUM (\"+A1 +1"), + A3: cell("="), + A4: cell("= "), + }}, + })); + const expected = calls.map(call => "_xlfn." + call).join("+") + suffix; + const worksheet = text(entries, "xl/worksheets/sheet2.xml"); + + expect(cellXml(worksheet, "A1")).toContain(`${expected}`); + // The grid tokenizer ignores whitespace, but in Excel `SUM (` is an intersection. + expect(cellXml(worksheet, "A2")).toContain('SUM(1,2)+_xlfn.IFS(TRUE,1)+"SUM ("+A1 +1'); + expect(cellXml(worksheet, "A3")).toContain('t="inlineStr">='); + expect(cellXml(worksheet, "A4")).toContain('t="inlineStr">= '); + }); + + it("exports formulas the grid tolerates but Excel would reject as text", async () => { + const invalid = ['=SUM(1,2', '="abc', "='Sheet!A1", "=(1))", "=Table1[A", "=A]1", '=SUM("a)",1']; + const valid = ['=SUM("(",")",""""")")', "='It''s'!A1+Table1[['#Header]]", "=(1+(2))"]; + const cells = Object.fromEntries([...invalid, ...valid].map((value, index) => [`A${index + 1}`, cell(value)])); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["sheet", "its"], + sheets: {sheet: sheet("Sheet"), its: sheet("It's")}, + cells: {sheet: cells, its: {}}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + + invalid.forEach((value, index) => { + expect(cellXml(worksheet, `A${index + 1}`)).toContain(`t="inlineStr">${value}`); + }); + valid.forEach((value, index) => { + expect(cellXml(worksheet, `A${invalid.length + index + 1}`)).toContain(`${value.slice(1)}`); + }); + }); + + it("translates ERRORTYPE function tokens to Excel's ERROR.TYPE name", async () => { + const value = '=ERRORTYPE(NA())+"ERRORTYPE("+ERRORTYPE!A1+Table1[ERRORTYPE(A1)]+' + + "'ERRORTYPE'!ERRORTYPE(A1)+[Book.xlsx]Sheet1!ERRORTYPE(A1)+ERROR.TYPE(NA())"; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["errorType", "formulas"], + sheets: {errorType: sheet("ERRORTYPE"), formulas: sheet("Formulas")}, + cells: {errorType: {A1: cell("value")}, formulas: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet2.xml"), "A1")).toContain( + 'ERROR.TYPE(NA())+"ERRORTYPE("+ERRORTYPE!A1+Table1[ERRORTYPE(A1)]+' + + "'ERRORTYPE'!ERRORTYPE(A1)+[Book.xlsx]Sheet1!ERRORTYPE(A1)+ERROR.TYPE(NA())"); + }); + + it("accepts an exactly maximum-size rewritten formula and rejects the next character", async () => { + const exact = "=+ERRORTYPE(NA())" + "+0".repeat(4087); + const overflow = "=ERRORTYPE(NA())" + "+0".repeat(4088); + expect(exact).toHaveLength(8191); + expect(overflow).toHaveLength(8192); + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data"], + sheets: {data: sheet("Data")}, + cells: {data: {A1: cell(exact), A2: cell(overflow)}}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + const exactXml = cellXml(worksheet, "A1"); + + expect(exactXml).toContain("+ERROR.TYPE(NA())"); + expect(exactXml).not.toContain("inlineStr"); + expect(cellXml(worksheet, "A2")) + .toBe(`${overflow}`); + }); + + it("tracks apostrophe-escaped brackets in structured references", async () => { + const value = "=Table1[[A'[B]]+IFS(TRUE,1)+Table1[[A']B]]+CONCAT(A1)+Table1[[A'']]+XOR(TRUE,FALSE)"; + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["data"], + sheets: {data: sheet("Data")}, + cells: {data: {A1: cell(value)}}, + })); + + expect(cellXml(text(entries, "xl/worksheets/sheet1.xml"), "A1")) + .toContain("Table1[[A'[B]]+_xlfn.IFS(TRUE,1)+Table1[[A']B]]+_xlfn.CONCAT(A1)+Table1[[A'']]+_xlfn.XOR(TRUE,FALSE)"); + }); + + it("prepares large duplicate sheet-name lists without quadratic suffix searches", async () => { + const sheetIds = Array.from({length: 15000}, (_, index) => `sheet-${index}`); + const metadata = Object.fromEntries(sheetIds.map((id, index) => [ + id, + sheet("x".repeat(27) + Math.floor(index / 2).toString(36).padStart(4, "0")), + ])); + const stream = workbookToXlsx({sheetOrder: sheetIds, sheets: metadata, cells: {}}); + + await stream.cancel(); + }); + + it("exports sparse typed cells safely and preserves dimensions and a combined frozen pane", async () => { + const unusual = "_x0041_" + String.fromCharCode(1, 0xd800, 13) + String.fromCodePoint(0x1f642); + const document = { + sheetOrder: ["data"], + sheets: { + data: sheet("Data", { + rows: 10, + cols: 12, + colWidths: {0: 100, 10: 2000, 11: 92, 12: 150}, + rowHeights: {1: 40, 9: 2000, 10: 80}, + frozenRows: 2, + frozenCols: 3, + }), + }, + cells: { + data: { + A1: cell(' <&>" \t\n'), + B1: cell(unusual), + C1: cell('=HYPERLINK("https://example.com","Example")'), + D1: cell("'001"), + E1: cell(" true "), + F1: cell("FALSE"), + G1: cell("+$1,234.50"), + H1: cell("-12.5%"), + I1: cell("42", {nf: "text"}), + J1: cell("https://example.com"), + K1: cell("==A1"), + L1: cell("2026-09-02", {nf: "date"}), + A10: cell('="_x0041_"'), + B10: cell("=A1", {nf: "text"}), + C10: cell("last"), + D10: cell("=[Book.xlsx]Data!A1+Jan:Data!A1"), + E10: cell(" "), + Z1: cell("outside declared columns"), + M1: cell("'=A1"), + N1: cell("TRUE", {nf: "text"}), + A11: cell("outside declared rows"), + XFD1048576: cell("last Excel cell"), + A0: cell("bad"), + a1: cell("bad"), + XFE1: cell("outside Excel"), + A1048577: cell("outside Excel"), + }, + }, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const xml = text(entries, "xl/worksheets/sheet1.xml"); + + expect(xml).toContain(''); + expect(text(entries, "xl/styles.xml")).toContain('numFmtId="49"'); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(cellXml(xml, "A1")).toContain(' <&>" \t\n'); + expect(cellXml(xml, "B1")).toContain("_x005F_x0041__x0001__xFFFD__x000D_"); + expect(cellXml(xml, "B1")).toContain(String.fromCodePoint(0x1f642)); + expect(cellXml(xml, "C1")).toContain('HYPERLINK("https://example.com","Example")'); + expect(cellXml(xml, "D1")).toContain(">001"); + expect(cellXml(xml, "E1")).toContain('t="b">1'); + expect(cellXml(xml, "F1")).toContain('t="b">0'); + expect(cellXml(xml, "G1")).toContain("1234.5"); + expect(cellXml(xml, "H1")).toContain("-0.125"); + expect(cellXml(xml, "I1")).toContain("42"); + expect(cellXml(xml, "J1")).toContain('t="inlineStr"'); + expect(cellXml(xml, "K1")).toContain("=A1"); + expect(cellXml(xml, "L1")).toContain('t="inlineStr"'); + expect(cellXml(xml, "A10")).toContain('"_x0041_"'); + expect(cellXml(xml, "B10")).toContain("A1"); + expect(styleId(xml, "B10")).toBe(styleId(xml, "I1")); + expect(cellXml(xml, "D10")).toContain("[Book.xlsx]Data!A1+Jan:Data!A1"); + expect(cellXml(xml, "E10")).toBe(''); + expect(cellXml(xml, "Z1")).toContain("outside declared columns"); + expect(cellXml(xml, "M1")).toContain(">=A1"); + expect(cellXml(xml, "N1")).toContain('t="b">1'); + expect(styleId(xml, "N1")).toBe(styleId(xml, "I1")); + expect(cellXml(xml, "A11")).toContain("outside declared rows"); + expect(cellXml(xml, "XFD1048576")).toContain("last Excel cell"); + for (const reference of ["A0", "a1", "XFE1", "A1048577"]) expect(xml).not.toContain(`r="${reference}"`); + }); + + it("batches worksheet XML while exporting the maximum stored cell count", async () => { + const cells: Record> = {}; + for (let index = 0; index < 200000; ++index) { + cells[String.fromCharCode(65 + index % 4) + (Math.floor(index / 4) + 1)] = cell("1"); + } + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["dense"], + sheets: {dense: sheet("Dense", {rows: 50000, cols: 4})}, + cells: {dense: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + + expect(worksheet).toContain(''); + expect(cellXml(worksheet, "D50000")).toContain("1"); + }); + + it("deduplicates styles while supporting every format field and number-format category", async () => { + const formats = { + A1: {b: true}, B1: {i: true}, C1: {u: true}, D1: {s: true}, + E1: {c: "#abc"}, F1: {bg: "#1234"}, G1: {a: "c"}, H1: {nf: "number", d: 3}, + I1: {fs: 18}, J1: {wrap: true}, K1: {nf: "text"}, L1: {nf: "integer"}, + M1: {nf: "currency"}, N1: {nf: "percent"}, O1: {nf: "scientific"}, + P1: {nf: "date"}, Q1: {nf: "time"}, R1: {nf: "datetime"}, + S1: {nf: "unknown"}, T1: {d: 4}, + }; + const cells: Record> = {}; + for (const [reference, fmt] of Object.entries(formats)) cells[reference] = cell("1", fmt); + const repeated = {b: true, bg: "#112233", a: "r"}; + cells.A2 = cell("same", repeated); + cells.B2 = cell("same", {...repeated}); + cells.C2 = cell("", {...repeated}); + cells.D2 = cell("eight digit", {c: "#abcdef12"}); + + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["styles"], + sheets: {styles: sheet("Styles", {rows: 2, cols: 20})}, + cells: {styles: cells}, + })); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + const styles = text(entries, "xl/styles.xml"); + + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain(""); + expect(styles).toContain('rgb="FFAABBCC"'); + expect(styles).toContain('rgb="44112233"'); + expect(styles).toContain('rgb="FF112233"'); + expect(styles).toContain('rgb="12ABCDEF"'); + expect(styles).toContain('horizontal="center"'); + expect(styles).toContain('horizontal="right"'); + expect(styles).toContain('wrapText="1"'); + expect(styles).toContain(''); + for (const code of [ + "#,##0.000", "#,##0", '"$"#,##0.00;-"$"#,##0.00', + "#,##0.00%", "0.00E+00", "mm/dd/yyyy", "h:mm:ss AM/PM", + "mm/dd/yyyy h:mm:ss AM/PM", "0.0000", + ]) expect(styles).toContain(`formatCode="${code}"`); + expect(styleId(worksheet, "S1")).toBeUndefined(); + expect(styleId(worksheet, "A2")).toBe(styleId(worksheet, "B2")); + expect(styleId(worksheet, "B2")).toBe(styleId(worksheet, "C2")); + expect(cellXml(worksheet, "C2")).toMatch(/^$/); + }); + + it("writes row-only, column-only, and combined frozen panes", async () => { + const {entries} = await readZip(workbookToXlsx({ + sheetOrder: ["rows", "columns", "both"], + sheets: { + rows: sheet("Rows", {frozenRows: 2}), + columns: sheet("Columns", {frozenCols: 3}), + both: sheet("Both", {frozenRows: 4, frozenCols: 5}), + }, + cells: {rows: {}, columns: {}, both: {}}, + })); + expect(text(entries, "xl/worksheets/sheet1.xml")).toContain('ySplit="2" topLeftCell="A3" activePane="bottomLeft"'); + expect(text(entries, "xl/worksheets/sheet2.xml")).toContain('xSplit="3" topLeftCell="D1" activePane="topRight"'); + expect(text(entries, "xl/worksheets/sheet3.xml")).toContain('xSplit="5" ySplit="4" topLeftCell="F5" activePane="bottomRight"'); + }); + + it("fails clearly before generating more fill styles than Excel supports", () => { + const cells: Record> = {}; + for (let index = 0; index < 255; ++index) { + const reference = String.fromCharCode(65 + index % 26) + (Math.floor(index / 26) + 1); + cells[reference] = cell("", {bg: `#${index.toString(16).padStart(6, "0")}`}); + } + expect(() => workbookToXlsx({ + sheetOrder: ["styles"], + sheets: {styles: sheet("Styles", {rows: 10, cols: 26})}, + cells: {styles: cells}, + })).toThrow("XLSX fill count exceeds Excel's limit of 256"); + }); + + it("ignores v5-only metadata while exporting ordinary and materialized pivot cells", async () => { + const document = { + sheetOrder: ["v5"], + sheets: { + v5: { + ...sheet("V5"), + filter: {range: "A1:B4"}, + charts: [{type: "bar"}], + comments: {A1: "note"}, + pivot: {source: "A1:B4", destination: "D1"}, + }, + }, + cells: { + v5: { + A1: {...cell("ordinary"), comment: "ignored"}, + D1: cell("Pivot total", {b: true}), + D2: cell("125"), + }, + }, + filter: {}, + charts: [], + comments: {}, + pivot: {}, + }; + const {entries} = await readZip(workbookToXlsx(document)); + const worksheet = text(entries, "xl/worksheets/sheet1.xml"); + expect(cellXml(worksheet, "A1")).toContain("ordinary"); + expect(cellXml(worksheet, "D1")).toContain("Pivot total"); + expect(cellXml(worksheet, "D2")).toContain("125"); + expect(worksheet).not.toContain("autoFilter"); + expect([...entries.keys()].some(name => /chart|comment|pivot/i.test(name))).toBe(false); + }); +}); + +describe("Workspace Sheets document snapshots", () => { + it("completes a queued document read before beginning the next mutation", async () => { + const {promise: readReleased, resolve: releaseRead} = Promise.withResolvers(); + const {promise: readStarted, resolve: markReadStarted} = Promise.withResolvers(); + const order: string[] = []; + const fixture = Object.assign(Object.create(Gadget.prototype), { + mutationQueue: Promise.resolve(), + loadMeta: vi.fn(async () => ({revision: 1})), + assembleDocument: vi.fn(async () => { + order.push("read started"); + markReadStarted(); + await readReleased; + order.push("read completed"); + return {revision: 1}; + }), + applyOperationLocked: vi.fn(async () => { + order.push("write started"); + order.push("write completed"); + return {result: {status: "applied"}}; + }), + }); + + const read = fixture.getDocument(); + await readStarted; + const write = fixture.applyOperation({}); + await Promise.resolve(); + expect(fixture.applyOperationLocked).not.toHaveBeenCalled(); + + releaseRead(); + await expect(read).resolves.toEqual({revision: 1}); + await expect(write).resolves.toEqual({status: "applied"}); + expect(order).toEqual(["read started", "read completed", "write started", "write completed"]); + }); + + it("lets a subscriber callback read and write the document without holding up the save", async () => { + const subscribers = new Map(); + const fixture = inMemoryGadget(subscribers); + const events: {revision: number}[] = []; + const documents: {revision: number; cells: Record>}[] = []; + const {promise: callbacksFinished, resolve: finishCallbacks} = Promise.withResolvers(); + subscribers.set({ + operation: vi.fn(async (event: {revision: number}) => { + events.push(event); + documents.push(await fixture.getDocument()); + if (event.revision === 1) await fixture.applyOperation(setCell("B1", "from callback")); + else finishCallbacks(); + }), + }, {}); + + const result = await fixture.applyOperation(setCell("A1", "committed")); + expect(result.status).toBe("applied"); + expect(result).not.toHaveProperty("result"); + expect(events).toHaveLength(1); + + await callbacksFinished; + expect(events.map(event => event.revision)).toEqual([1, 2]); + expect(events[0]).not.toHaveProperty("status"); + expect(events[0]).not.toHaveProperty("conflicts"); + expect(documents[0].revision).toBe(1); + expect(documents[0].cells.sheet.A1.value).toBe("committed"); + expect(documents[1].cells.sheet.B1.value).toBe("from callback"); + }); + + it("drops and disposes a subscriber whose callback fails, and does not wait on one that hangs", async () => { + const failing = {operation: vi.fn(async () => { throw new Error("broken"); }), [Symbol.dispose]: vi.fn()}; + const hung = {operation: vi.fn(() => new Promise(() => {})), [Symbol.dispose]: vi.fn()}; + const fixture = inMemoryGadget(new Map([[failing, {}], [hung, {}]])); + + const result = await fixture.applyOperation(setCell("A1", "value")); + expect(result.status).toBe("applied"); + await vi.waitFor(() => expect(fixture.subscribers.has(failing)).toBe(false)); + expect(failing[Symbol.dispose]).toHaveBeenCalledOnce(); + expect(hung.operation).toHaveBeenCalledOnce(); + expect(fixture.subscribers.has(hung)).toBe(true); + expect(hung[Symbol.dispose]).not.toHaveBeenCalled(); + }); + + it("does not broadcast unchanged or conflicting-only operations", async () => { + const subscriber = {operation: vi.fn()}; + const fixture = inMemoryGadget(new Map([[subscriber, {}]])); + await fixture.applyOperation(setCell("A1", "first")); + const conflict = await fixture.applyOperation(setCell("A1", "stale", 0)); + const unchanged = await fixture.applyOperation({senderId: "test", cellOps: []}); + + expect(conflict.status).toBe("conflict"); + expect(conflict.conflicts).toHaveLength(1); + expect(unchanged.status).toBe("unchanged"); + expect(subscriber.operation).toHaveBeenCalledOnce(); + }); + + it("registers a subscriber and takes its snapshot inside the mutation queue", async () => { + const fixture = inMemoryGadget(); + const newcomer = {presence: vi.fn(), operation: vi.fn(), onRpcBroken: vi.fn()}; + const {promise: writeReleased, resolve: releaseWrite} = Promise.withResolvers(); + const original = fixture.applyOperationLocked.bind(fixture); + fixture.applyOperationLocked = async (operation: unknown) => { await writeReleased; return original(operation); }; + + const writing = fixture.applyOperation(setCell("A1", "before subscribe")); + const callback = {dup: vi.fn(() => newcomer)}; + const subscribing = fixture.subscribe(callback as never, {clientId: "newcomer"}); + await Promise.resolve(); + expect(callback.dup).not.toHaveBeenCalled(); + + releaseWrite(); + await writing; + const document = await subscribing; + expect(document.revision).toBe(1); + expect(document.cells.sheet.A1.value).toBe("before subscribe"); + expect(fixture.subscribers.has(newcomer)).toBe(true); + expect(newcomer.operation).not.toHaveBeenCalled(); + }); + + it("does not duplicate the callback when the snapshot fails", async () => { + const fixture = inMemoryGadget(); + const loadMeta = fixture.loadMeta.bind(fixture); + fixture.loadMeta = vi.fn(loadMeta).mockRejectedValueOnce(new Error("storage unavailable")); + const callback = {dup: vi.fn()}; + + await expect(fixture.subscribe(callback as never)).rejects.toThrow("storage unavailable"); + expect(callback.dup).not.toHaveBeenCalled(); + expect(fixture.subscribers.size).toBe(0); + await expect(fixture.applyOperation(setCell("A1", "still works"))).resolves.toMatchObject({status: "applied"}); + }); +}); + +describe("Workspace Sheets export formats", () => { + it("reserves one of 32 slots for XLSX and applies the same CSV eligibility rules at export", async () => { + const ids = Array.from({length: 40}, (_, index) => `sheet-${index}`); + const longId = "x".repeat(125); + const sheetOrder = [ids[0], ids[0], longId, ...ids.slice(1)]; + const sheets = Object.fromEntries([...ids, longId].map(id => [id, sheet(id)])); + const document = {sheetOrder, sheets, cells: Object.fromEntries(Object.keys(sheets).map(id => [id, {}]))}; + const gadget = {getDocument: vi.fn(async () => document)}; + + const formats = await handler().getExportFormats(gadget as never); + expect(formats).toHaveLength(32); + expect(formats[0]).toEqual({ + id: "xlsx", + label: "Excel Workbook", + mode: "server", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + fileExtension: ".xlsx", + }); + expect(new Set(formats.map(format => format.id)).size).toBe(32); + expect(formats.some(format => format.id === `csv:${longId}`)).toBe(false); + expect(formats.some(format => format.id === "csv:sheet-39")).toBe(false); + await expect(handler().export(gadget as never, "csv:sheet-39")).rejects.toThrow("unavailable"); + await expect(handler().export(gadget as never, "pdf")).rejects.toThrow("Unsupported"); + }); + + it("retains raw-value CSV behavior and materializes state before returning an XLSX stream", async () => { + const document = { + sheetOrder: ["one"], + sheets: {one: sheet("One", {rows: 2, cols: 3})}, + cells: {one: { + A1: cell("a,b"), + C1: cell('say "hi"'), + B2: cell("=SUM(A1:A2)"), + }}, + }; + const gadget = {getDocument: vi.fn(async () => document)}; + const csv = await handler().export(gadget as never, "csv:one"); + expect(await new Response(csv).text()).toBe('"a,b",,"say ""hi"""\r\n,=SUM(A1:A2),\r\n'); + + const xlsx = await handler().export(gadget as never, "xlsx"); + expect(gadget.getDocument).toHaveBeenCalledTimes(2); + gadget.getDocument.mockImplementation(async () => { throw new Error("borrowed capability reused"); }); + const {entries} = await readZip(xlsx); + expect(cellXml(text(entries, "xl/worksheets/sheet1.xml"), "B2")).toContain("SUM(A1:A2)"); + }); +}); diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md b/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md index cc8e2ed195..6bc719dbe8 100644 --- a/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/README.md @@ -16,6 +16,7 @@ A lightweight, persistent spreadsheet Gadget with a familiar grid interface, for - Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits - Automatic persistent saving with optimistic per-cell conflict detection - Real-time operation and presence synchronization in the server architecture +- Excel workbook and per-sheet CSV export ## Using the spreadsheet @@ -173,11 +174,18 @@ Formula evaluation happens in the browser. The engine caches computed cells, det Exports the Durable Object class `Gadget`, which is the authoritative persistence and synchronization layer. It: - Stores spreadsheet metadata and each sheet's cells in Durable Object storage -- Serializes mutations through an in-memory queue +- Serializes mutations and document snapshots through an in-memory queue - Applies per-cell optimistic concurrency using cell versions - Uses last-writer-wins semantics for document structure -- Broadcasts operations and presence events to subscribed clients +- Broadcasts operations and presence events to subscribed clients after the queue releases, best-effort and without awaiting them, so a callback may itself read or write the document and a hung subscriber holds up only its own client - Sanitizes titles, dimensions, cell contents, references, and formatting +- Advertises and produces the server-side workbook and CSV exports + +### `xlsx.js` and `zip.js` + +`xlsx.js` converts a complete document snapshot into a streaming XLSX workbook: sparse worksheet XML +with inline strings, deduplicated styles, and frozen panes. `zip.js` is a dependency-free streaming +ZIP writer (raw DEFLATE via `CompressionStream`, incremental CRC32, data descriptors). ## Storage model @@ -202,7 +210,36 @@ The server and client synchronization code support multiple connected clients an ## CSV export -Each worksheet is exposed as its own **CSV** export option. CSV files contain the stored cell values -through the worksheet's used range. Formula cells are exported as their raw formulas (for example, -`=SUM(A1:A10)`), not as browser-computed display values. Fields use standard CSV quoting and CRLF -line endings. +Up to 31 worksheets are exposed as individual **CSV** export options, leaving one of the platform's +32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's used +range. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as +browser-computed display values. Fields use standard CSV quoting and CRLF line endings. + +## XLSX export + +**Excel Workbook** exports one XLSX file with the worksheets in workbook order. Cells keep their +font, color, fill, alignment, number format, decimal places and wrapping; sheets keep column widths, +row heights and frozen panes. Pixel sizes are converted to points (rows, fonts) or approximate +character widths (columns), so layout is close but not pixel-identical. + +Cell values follow the grid's own literal rules: a leading apostrophe forces text, a leading `=` is +a formula (whatever the number format), `TRUE`/`FALSE` are booleans, and numbers may carry a sign, +commas, a leading `$` or a trailing `%`. Everything else is text; date-looking text is not parsed. + +Formulas are written without cached results and the workbook requests a full recalculation on open, +so Excel evaluates them itself. To keep them valid there, the exporter rewrites cross-sheet +references to the exported worksheet names, prefixes OOXML "future functions" (`IFS`, `CONCAT`, ...) +with `_xlfn.`, renames `ERRORTYPE()` to `ERROR.TYPE()`, and drops whitespace between a function +name and its `(`. A formula that is empty, structurally unbalanced (unterminated string or quoted +name, mismatched parentheses or brackets — the grid's parser tolerates these) or that would exceed +Excel's 8,192-character limit after rewriting is exported as text, since one such formula makes +Excel report the whole workbook as damaged. Formula semantics are otherwise not translated (for +example `^` associativity differs, and a reference outside the grid such as `XFE1` is empty here +but `#NAME?` in Excel), and compatibility with Excel is not claimed beyond this. + +Worksheet names are made Excel-safe: invalid characters become `_`, blank names become `Sheet`, +names are cut to 31 characters, and case-insensitive collisions get ` (2)`, ` (3)`, ... suffixes. +References resolve to the first worksheet with a matching source name, as in the grid. + +Filters, charts, comments and pivot tables are not exported; cells already materialized from them +export as ordinary values. diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js index 1f200bf43c..faaa83396b 100644 --- a/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/server.js @@ -1,4 +1,5 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; +import { workbookToXlsx } from "./xlsx.js"; const DEFAULT_TITLE = "Untitled spreadsheet"; const DEFAULT_ROWS = 100; @@ -21,7 +22,8 @@ export class Gadget extends DurableObject { this.ctx = ctx; this.subscribers = new Map(); // Overlapping RPC calls are serialized so each observes/commits one - // authoritative state in strict order. + // authoritative state in strict order. Callbacks to subscribers run + // outside the queue, so a callback may itself read or write the document. this.mutationQueue = Promise.resolve(); } @@ -70,12 +72,17 @@ export class Gadget extends DurableObject { }; } - async getDocument() { - return this.assembleDocument(await this.loadMeta()); + getDocument() { + return this.enqueueMutation(async () => this.assembleDocument(await this.loadMeta())); } - applyOperation(operation) { - return this.enqueueMutation(() => this.applyOperationLocked(operation)); + async applyOperation(operation) { + const { result, event } = await this.enqueueMutation(() => this.applyOperationLocked(operation)); + // Issued after the queue releases, so callbacks may re-enter it, but + // synchronously here, before the next queued mutation can reach storage, + // so each subscriber still receives events in revision order. + if (event) this.broadcast(event); + return result; } async applyOperationLocked(operation) { @@ -168,7 +175,7 @@ export class Gadget extends DurableObject { if (upserts.length || deletes.length) changed = true; if (!changed) { - return { status: conflicts.length ? "conflict" : "unchanged", revision: meta.revision, conflicts }; + return { result: { status: conflicts.length ? "conflict" : "unchanged", revision: meta.revision, conflicts } }; } meta.revision += 1; @@ -193,39 +200,43 @@ export class Gadget extends DurableObject { event.replacedCells = {}; for (const id of event.replacedSheets) event.replacedCells[id] = await this.loadCells(id); } - await this.broadcast(event); - - return { status: conflicts.length ? "conflict" : "applied", ...event, conflicts }; + return { result: { status: conflicts.length ? "conflict" : "applied", ...event, conflicts }, event }; } // --- Presence & subscription ------------------------------------------ async subscribe(callback, client = {}) { - const dup = callback.dup(); - const existing = Array.from(this.subscribers.values()); const info = { - callback: dup, clientId: String(client.clientId || ""), name: String(client.name || "Guest").slice(0, 40), color: String(client.color || "#e1632e"), }; - this.subscribers.set(dup, info); - dup.onRpcBroken(() => { - this.subscribers.delete(dup); - this.broadcastPresence({ type: "leave", clientId: info.clientId }); - }); - queueMicrotask(async () => { - for (const person of existing) { - try { - await dup.presence({ type: "join", clientId: person.clientId, name: person.name, color: person.color }); - } catch (e) { break; } - } - await this.broadcastPresence({ type: "join", clientId: info.clientId, name: info.name, color: info.color }); + // Registering and snapshotting inside the queue means the subscriber sees + // every operation committed after its snapshot, and none before it. The + // callback is duplicated only once the snapshot exists, so a failed read + // leaves nothing to dispose. + return this.enqueueMutation(async () => { + const document = await this.assembleDocument(await this.loadMeta()); + const existing = Array.from(this.subscribers.values()); + const dup = callback.dup(); + this.subscribers.set(dup, info); + dup.onRpcBroken(() => { + this.dropSubscriber(dup); + this.broadcastPresence({ type: "leave", clientId: info.clientId }); + }); + queueMicrotask(async () => { + for (const person of existing) { + try { + await dup.presence({ type: "join", clientId: person.clientId, name: person.name, color: person.color }); + } catch (e) { break; } + } + this.broadcastPresence({ type: "join", clientId: info.clientId, name: info.name, color: info.color }); + }); + return document; }); - return this.assembleDocument(await this.loadMeta()); } - async updatePresence(presence) { - await this.broadcastPresence({ + updatePresence(presence) { + this.broadcastPresence({ type: "cursor", clientId: String(presence.clientId || ""), name: String(presence.name || "Guest").slice(0, 40), @@ -237,24 +248,26 @@ export class Gadget extends DurableObject { }); } - async leavePresence(clientId) { - await this.broadcastPresence({ type: "leave", clientId: String(clientId || ""), at: Date.now() }); + leavePresence(clientId) { + this.broadcastPresence({ type: "leave", clientId: String(clientId || ""), at: Date.now() }); } - async broadcast(event) { - const calls = []; + dropSubscriber(stub) { + if (this.subscribers.delete(stub)) stub[Symbol.dispose](); + } + + // Delivery is best-effort and not awaited: a callback that fails is dropped, + // and one that never settles holds up nothing but its own client. + broadcast(event) { for (const [stub] of this.subscribers) { - calls.push(Promise.resolve(stub.operation(event)).catch(() => this.subscribers.delete(stub))); + Promise.resolve(stub.operation(event)).catch(() => this.dropSubscriber(stub)); } - await Promise.all(calls); } - async broadcastPresence(event) { - const calls = []; + broadcastPresence(event) { for (const [stub] of this.subscribers) { - calls.push(Promise.resolve(stub.presence(event)).catch(() => this.subscribers.delete(stub))); + Promise.resolve(stub.presence(event)).catch(() => this.dropSubscriber(stub)); } - await Promise.all(calls); } } @@ -322,30 +335,48 @@ function sanitizeCellMap(map) { return out; } - const CSV_FORMAT_PREFIX = "csv:"; -const MAX_CSV_SHEETS = 32; +const MAX_CSV_SHEETS = 31; // The platform allows 32 formats; one is the workbook. +const MAX_EXPORT_ID_LENGTH = 128; +const XLSX_FORMAT = { + id: "xlsx", + label: "Excel Workbook", + mode: "server", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + fileExtension: ".xlsx", +}; + +// Sheet ids are client-chosen, so duplicates and over-long ids are possible in +// stored structure. Either would fail format validation and disable every export. +function csvSheetIds(document) { + const ids = document.sheetOrder.filter((id) => (CSV_FORMAT_PREFIX + id).length <= MAX_EXPORT_ID_LENGTH); + return Array.from(new Set(ids)).slice(0, MAX_CSV_SHEETS); +} export class ExportHandler extends WorkerEntrypoint { async getExportFormats(gadget) { const document = await gadget.getDocument(); - const sheetIds = document.sheetOrder.slice(0, MAX_CSV_SHEETS); - return sheetIds.map((sheetId) => ({ + const sheetIds = csvSheetIds(document); + return [XLSX_FORMAT, ...sheetIds.map((sheetId) => ({ id: CSV_FORMAT_PREFIX + sheetId, label: sheetIds.length === 1 ? "CSV" : "CSV (" + document.sheets[sheetId].name + ")", mode: "server", contentType: "text/csv", fileExtension: ".csv", - })); + }))]; } async export(gadget, id) { + if (id === XLSX_FORMAT.id) { + const document = await gadget.getDocument(); + return workbookToXlsx(document); + } if (!id.startsWith(CSV_FORMAT_PREFIX)) { throw new Error("Unsupported spreadsheet export format: " + id); } const document = await gadget.getDocument(); const sheetId = id.slice(CSV_FORMAT_PREFIX.length); - if (!document.sheetOrder.slice(0, MAX_CSV_SHEETS).includes(sheetId)) { + if (!csvSheetIds(document).includes(sheetId)) { throw new Error("The selected worksheet is unavailable for CSV export."); } return new Response(workbookSheetToCsv(document, sheetId)).body; @@ -353,7 +384,7 @@ export class ExportHandler extends WorkerEntrypoint { } function workbookSheetToCsv(document, sheetId) { - const cells = document.cells[sheetId] || {}; + const cells = document.cells?.[sheetId] || {}; let maxRow = -1; let maxColumn = -1; for (const [ref, cell] of Object.entries(cells)) { diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js new file mode 100644 index 0000000000..84e6aac4f8 --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/xlsx.js @@ -0,0 +1,715 @@ +import { createZip } from "./zip.js"; + +const encoder = new TextEncoder(); +const MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; +const MAX_ROWS = 1048576; +const MAX_COLUMNS = 16384; +const DEFAULT_ROWS = 100; +const DEFAULT_COLUMNS = 26; +const DEFAULT_ROW_PIXELS = 24; +const DEFAULT_COLUMN_PIXELS = 92; +const MAX_FONTS = 512; +const MAX_FILLS = 256; +const MAX_CELL_FORMATS = 65490; +const MAX_FORMULA_CHARACTERS = 8192; +const TEXT_CHUNK_SIZE = 64 * 1024; +const FUTURE_FUNCTIONS = new Set([ + "CONCAT", "DAYS", "IFNA", "IFS", "SWITCH", "TEXTJOIN", "UNICHAR", "UNICODE", "XOR", +]); + +function spreadsheetXml(value, attribute = false) { + const input = String(value).replace(/_x[0-9a-f]{4}_/gi, (match) => "_x005F_" + match.slice(1)); + let clean = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) clean += input[i] + input[++i]; + else clean += "_xFFFD_"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + clean += "_xFFFD_"; + } else if (code === 13) { + clean += "_x000D_"; + } else if (code === 9 || code === 10 || + (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd)) { + clean += input[i]; + } else { + clean += `_x${code.toString(16).toUpperCase().padStart(4, "0")}_`; + } + } + clean = clean.replace(/&/g, "&").replace(//g, ">"); + if (attribute) clean = clean.replace(/"/g, """).replace(/'/g, "'"); + return clean; +} + +function formulaXml(value) { + const input = String(value); + let clean = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) clean += input[i] + input[++i]; + else clean += String.fromCharCode(0xfffd); + } else if (code >= 0xdc00 && code <= 0xdfff) { + clean += String.fromCharCode(0xfffd); + } else if (code === 9 || code === 10 || code === 13 || + (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd)) { + clean += input[i]; + } else { + clean += String.fromCharCode(0xfffd); + } + } + return clean.replace(/&/g, "&").replace(//g, ">") + .replace(/\r/g, " "); +} + +function xmlAttribute(value) { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +// Encodes a string generator into ~64 KiB byte chunks. Cell-sized chunks would make the ZIP's +// CompressionStream the bottleneck. `highWaterMark: 0` keeps generation lazy until the archive +// reaches this part. +function textStream(generator) { + return new ReadableStream({ + pull(controller) { + const parts = []; + let length = 0; + while (length < TEXT_CHUNK_SIZE) { + const result = generator.next(); + if (result.done) { + if (parts.length) controller.enqueue(encoder.encode(parts.join(""))); + controller.close(); + return; + } + parts.push(result.value); + length += result.value.length; + } + controller.enqueue(encoder.encode(parts.join(""))); + }, + cancel(reason) { + generator.return(reason); + }, + }, {highWaterMark: 0}); +} + +function count(value, fallback, maximum) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number)) return fallback; + return Math.max(1, Math.min(maximum, number)); +} + +function frozenCount(value, maximum) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number)) return 0; + return Math.max(0, Math.min(50, maximum, number)); +} + +function truncateSheetName(value, length) { + const input = value.slice(0, length); + let result = ""; + for (let i = 0; i < input.length; ++i) { + const code = input.charCodeAt(i); + if (code < 32 || (code >= 127 && code <= 159)) { + result += "_"; + } else if (code >= 0xd800 && code <= 0xdbff) { + const low = input.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) result += input[i] + input[++i]; + else result += "_"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + result += "_"; + } else { + result += input[i]; + } + } + return result; +} + +function safeSheetName(value) { + let name = String(value ?? "").replace(/[:\\/?*\[\]]/g, "_").trim(); + name = truncateSheetName(name, 31); + if (name.startsWith("'")) name = "_" + name.slice(1); + if (name.endsWith("'")) name = name.slice(0, -1) + "_"; + if (name.toLowerCase() === "history") name += "_"; + return name || "Sheet"; +} + +function assignSheetNames(sheets) { + const used = new Set(); + // Next unused suffix per truncated stem (keyed with the suffix's digit count, since the stem + // shrinks to make room), so N same-named sheets take O(N) probes rather than O(N²). + const nextSuffixes = new Map(); + for (const sheet of sheets) { + const base = safeSheetName(sheet.sourceName); + let name = base; + let suffix = 2; + while (used.has(name.toLowerCase())) { + const digits = String(suffix).length; + const stem = truncateSheetName(base, 28 - digits); + const key = `${stem.toLowerCase()}|${digits}`; + const next = nextSuffixes.get(key) ?? suffix; + if (next > suffix) { + suffix = next; + continue; + } + name = `${stem} (${suffix})`; + nextSuffixes.set(key, ++suffix); + } + used.add(name.toLowerCase()); + sheet.name = name; + } +} + +function parseCellReference(reference) { + const match = /^([A-Z]+)([1-9]\d*)$/.exec(reference); + if (!match) return null; + let column = 0; + for (const character of match[1]) { + column = column * 26 + character.charCodeAt(0) - 64; + if (column > MAX_COLUMNS) return null; + } + const row = Number(match[2]); + if (!Number.isSafeInteger(row) || row > MAX_ROWS) return null; + return {row, column}; +} + +function columnName(column) { + let name = ""; + for (let value = column; value > 0; value = Math.floor((value - 1) / 26)) { + name = String.fromCharCode(65 + (value - 1) % 26) + name; + } + return name; +} + +function pixelDimension(value) { + const pixels = Math.round(Number(value)); + return Number.isFinite(pixels) && pixels >= 8 && pixels <= 2000 ? pixels : null; +} + +function rowPoints(pixels) { + return String(Math.min(409, Math.round(pixels * 75) / 100)); +} + +function columnWidth(pixels) { + return String(Math.min(255, Math.round(Math.max(0, (pixels - 5) / 7) * 256) / 256)); +} + +function dimensions(source, maximum, convert) { + const result = []; + if (!source || typeof source !== "object") return result; + for (const [key, value] of Object.entries(source)) { + if (!/^(0|[1-9]\d*)$/.test(key)) continue; + const index = Number(key); + const pixels = pixelDimension(value); + if (!Number.isSafeInteger(index) || index < 0 || index >= maximum || pixels == null) continue; + result.push({index, value: convert(pixels)}); + } + result.sort((a, b) => a.index - b.index); + return result; +} + +function xlsxColor(value) { + if (typeof value !== "string") return null; + const hex = value.slice(1); + if (!value.startsWith("#") || ![3, 4, 6, 8].includes(hex.length) || !/^[0-9a-f]+$/i.test(hex)) return null; + if (hex.length === 3) return "FF" + Array.from(hex, character => character + character).join("").toUpperCase(); + if (hex.length === 4) { + const [r, g, b, a] = Array.from(hex, character => character + character); + return (a + r + g + b).toUpperCase(); + } + if (hex.length === 6) return "FF" + hex.toUpperCase(); + return (hex.slice(6) + hex.slice(0, 6)).toUpperCase(); +} + +function decimals(fmt) { + if (fmt?.d == null) return null; + const value = Math.round(Number(fmt?.d)); + return Number.isFinite(value) && value >= 0 && value <= 10 ? value : null; +} + +function decimalPattern(value) { + return value ? "." + "0".repeat(value) : ""; +} + +class Styles { + constructor() { + this.fonts = [{size: 11}]; + this.fontIds = new Map(); + this.fills = [null, {gray125: true}]; + this.fillIds = new Map(); + this.numberFormats = []; + this.numberFormatIds = new Map(); + this.alignments = [null]; + this.alignmentIds = new Map(); + this.cellFormats = [{fontId: 0, fillId: 0, numberFormatId: 0, alignmentId: 0}]; + this.cellFormatIds = new Map(); + } + + font(fmt) { + const color = xlsxColor(fmt?.c); + const pixels = Math.round(Number(fmt?.fs)); + // The grid renders `fs` in CSS pixels; Excel font sizes are points. + const size = Number.isFinite(pixels) && pixels >= 6 && pixels <= 96 ? pixels * 0.75 : null; + const font = { + bold: Boolean(fmt?.b), italic: Boolean(fmt?.i), underline: Boolean(fmt?.u), + strike: Boolean(fmt?.s), color, size, + }; + if (!font.bold && !font.italic && !font.underline && !font.strike && !font.color && !font.size) return 0; + const key = JSON.stringify(font); + let id = this.fontIds.get(key); + if (id == null) { + if (this.fonts.length >= MAX_FONTS) throw new Error("XLSX font count exceeds Excel's limit of 512."); + id = this.fonts.length; + this.fontIds.set(key, id); + this.fonts.push(font); + } + return id; + } + + fill(fmt) { + const color = xlsxColor(fmt?.bg); + if (!color) return 0; + let id = this.fillIds.get(color); + if (id == null) { + if (this.fills.length >= MAX_FILLS) throw new Error("XLSX fill count exceeds Excel's limit of 256."); + id = this.fills.length; + this.fillIds.set(color, id); + this.fills.push({color}); + } + return id; + } + + customNumberFormat(code) { + let id = this.numberFormatIds.get(code); + if (id == null) { + if (164 + this.numberFormats.length > 0xffff) { + throw new Error("XLSX number format count exceeds the format ID limit of 65,535."); + } + id = 164 + this.numberFormats.length; + this.numberFormatIds.set(code, id); + this.numberFormats.push({id, code}); + } + return id; + } + + numberFormat(fmt) { + const places = decimals(fmt); + const name = fmt?.nf; + if (name === "text") return 49; + if (name === "integer") return this.customNumberFormat("#,##0"); + if (name === "number") return this.customNumberFormat("#,##0" + decimalPattern(places ?? 2)); + if (name === "currency") { + const pattern = '"$"#,##0' + decimalPattern(places ?? 2); + return this.customNumberFormat(pattern + ";-" + pattern); + } + if (name === "percent") return this.customNumberFormat("#,##0" + decimalPattern(places ?? 2) + "%"); + if (name === "scientific") return this.customNumberFormat("0" + decimalPattern(places ?? 2) + "E+00"); + if (name === "date") return this.customNumberFormat("mm/dd/yyyy"); + if (name === "time") return this.customNumberFormat("h:mm:ss AM/PM"); + if (name === "datetime") return this.customNumberFormat("mm/dd/yyyy h:mm:ss AM/PM"); + if (name != null) return 0; + return places == null ? 0 : this.customNumberFormat("0" + decimalPattern(places)); + } + + alignment(fmt) { + const horizontal = fmt?.a === "l" ? "left" : fmt?.a === "c" ? "center" : fmt?.a === "r" ? "right" : null; + const wrap = Boolean(fmt?.wrap); + if (!horizontal && !wrap) return 0; + const key = `${horizontal || ""}|${wrap}`; + let id = this.alignmentIds.get(key); + if (id == null) { + id = this.alignments.length; + this.alignmentIds.set(key, id); + this.alignments.push({horizontal, wrap}); + } + return id; + } + + style(fmt) { + if (!fmt || typeof fmt !== "object") return 0; + const cellFormat = { + fontId: this.font(fmt), fillId: this.fill(fmt), numberFormatId: this.numberFormat(fmt), + alignmentId: this.alignment(fmt), + }; + if (!cellFormat.fontId && !cellFormat.fillId && !cellFormat.numberFormatId && !cellFormat.alignmentId) return 0; + const key = `${cellFormat.fontId}|${cellFormat.fillId}|${cellFormat.numberFormatId}|${cellFormat.alignmentId}`; + let id = this.cellFormatIds.get(key); + if (id == null) { + if (this.cellFormats.length >= MAX_CELL_FORMATS) { + throw new Error("XLSX cell format count exceeds Excel's limit of 65,490."); + } + id = this.cellFormats.length; + this.cellFormatIds.set(key, id); + this.cellFormats.push(cellFormat); + } + return id; + } +} + +function sourceSheets(document) { + const result = []; + const seen = new Set(); + const order = Array.isArray(document?.sheetOrder) ? document.sheetOrder : []; + const sheetMap = document?.sheets && typeof document.sheets === "object" ? document.sheets : {}; + const cellMap = document?.cells && typeof document.cells === "object" ? document.cells : {}; + for (const rawId of order) { + const id = String(rawId); + if (seen.has(id)) continue; + seen.add(id); + const metadata = sheetMap[id]; + if (!metadata || typeof metadata !== "object") continue; + result.push({ + id, + sourceName: typeof metadata.name === "string" ? metadata.name : "Sheet", + metadata, + sourceCells: cellMap[id] && typeof cellMap[id] === "object" ? cellMap[id] : {}, + }); + } + if (!result.length) result.push({id: "", sourceName: "Sheet", metadata: {}, sourceCells: {}}); + assignSheetNames(result); + return result; +} + +function prepareWorkbook(document) { + const sheets = sourceSheets(document); + const formulaNames = new Map(); + for (const sheet of sheets) { + const key = sheet.sourceName.toLowerCase(); + if (!formulaNames.has(key)) formulaNames.set(key, sheet.name); + } + const styles = new Styles(); + for (const sheet of sheets) { + sheet.rows = count(sheet.metadata.rows, DEFAULT_ROWS, MAX_ROWS); + sheet.columns = count(sheet.metadata.cols, DEFAULT_COLUMNS, MAX_COLUMNS); + sheet.frozenRows = frozenCount(sheet.metadata.frozenRows, sheet.rows); + sheet.frozenColumns = frozenCount(sheet.metadata.frozenCols, sheet.columns); + sheet.columnWidths = dimensions(sheet.metadata.colWidths, sheet.columns, columnWidth); + sheet.rowHeights = dimensions(sheet.metadata.rowHeights, sheet.rows, rowPoints); + sheet.cells = []; + for (const [reference, sourceCell] of Object.entries(sheet.sourceCells)) { + const position = parseCellReference(reference); + if (!position || !sourceCell || typeof sourceCell !== "object") continue; + const style = styles.style(sourceCell.fmt); + const value = sourceCell.value == null ? "" : String(sourceCell.value); + if (value === "" && !style) continue; + sheet.cells.push({reference, ...position, value, style}); + } + delete sheet.sourceCells; + sheet.cells.sort((a, b) => a.row - b.row || a.column - b.column); + } + return {sheets, styles, formulaNames}; +} + +function formulaReferenceAt(formula, offset) { + const match = /^\$?([A-Za-z]{1,3})\$?([1-9]\d*)/.exec(formula.slice(offset)); + if (!match) return false; + let column = 0; + for (const character of match[1].toUpperCase()) column = column * 26 + character.charCodeAt(0) - 64; + if (column > MAX_COLUMNS || Number(match[2]) > MAX_ROWS) return false; + const next = formula[offset + match[0].length]; + return !next || !/[A-Za-z0-9_$]/.test(next); +} + +function quotedSheetReference(formula, offset, names) { + const nameParts = []; + for (let i = offset + 1; i < formula.length; ++i) { + if (formula[i] !== "'") { + nameParts.push(formula[i]); + continue; + } + if (formula[i + 1] === "'") { + nameParts.push("'"); + ++i; + continue; + } + const quoteEnd = i + 1; + const hasBang = formula[quoteEnd] === "!"; + const end = quoteEnd + (hasBang ? 1 : 0); + const text = formula.slice(offset, end); + const name = nameParts.join(""); + const normalized = names.get(name.toLowerCase()); + const malformed = offset > 0 && /[A-Za-z0-9_.$]/.test(formula[offset - 1]); + const external = formula[offset - 1] === "]" || (!normalized && /\[[^\]]*\]/.test(name)); + if (!hasBang || !formulaReferenceAt(formula, end) || malformed || external || + isThreeDimensionalReference(formula, offset)) return {end, text}; + return normalized + ? {end, text: `'${normalized.replace(/'/g, "''")}'!`} + : {end, text}; + } + return null; +} + +function unquotedSheetReference(formula, offset, names) { + if (!/[A-Za-z_$]/.test(formula[offset]) || + (offset > 0 && /[A-Za-z0-9_.$]/.test(formula[offset - 1])) || + formula[offset - 1] === "]" || isThreeDimensionalReference(formula, offset)) return null; + let end = offset + 1; + while (end < formula.length && /[A-Za-z0-9_.$]/.test(formula[end])) ++end; + if (formula[end] !== "!" || !formulaReferenceAt(formula, end + 1)) return null; + const name = formula.slice(offset, end); + const normalized = names.get(name.toLowerCase()); + if (!normalized) return null; + if (normalized.toLowerCase() === name.toLowerCase()) { + return {end: end + 1, text: formula.slice(offset, end + 1)}; + } + return {end: end + 1, text: `'${normalized.replace(/'/g, "''")}'!`}; +} + +function isThreeDimensionalReference(formula, offset) { + if (formula[offset - 1] !== ":") return false; + let start = offset - 2; + while (start >= 0 && /[A-Za-z0-9_$]/.test(formula[start])) --start; + const preceding = formula.slice(start + 1, offset - 1); + return !/^\$?[A-Za-z]{1,3}\$?[1-9]\d*$/.test(preceding); +} + +// Recognizes a function call at `offset`. The grid's tokenizer discards whitespace, so it accepts +// `SUM (1)`; in Excel that space is the intersection operator, so the gap is dropped here. +function formulaFunctionAt(formula, offset) { + if (!/[A-Za-z_]/.test(formula[offset]) || + (offset > 0 && /[A-Za-z0-9_.$!]/.test(formula[offset - 1]))) return null; + let end = offset + 1; + while (end < formula.length && /[A-Za-z0-9_.]/.test(formula[end])) ++end; + let parenthesis = end; + while (parenthesis < formula.length && /\s/.test(formula[parenthesis])) ++parenthesis; + if (formula[parenthesis] !== "(") return null; + const name = formula.slice(offset, end).toUpperCase(); + if (FUTURE_FUNCTIONS.has(name)) return {end: parenthesis, text: "_xlfn." + name}; + if (name === "ERRORTYPE") return {end: parenthesis, text: "ERROR.TYPE"}; + return parenthesis > end ? {end: parenthesis, text: formula.slice(offset, end)} : null; +} + +// Rewrites sheet and function names for Excel. Returns null when the formula is unbalanced +// (unterminated string or quoted name, mismatched parentheses or brackets): the grid's parser +// tolerates those, but one such `` makes Excel report the whole workbook as damaged. +function rewriteFormula(formula, names) { + const result = []; + let stringLiteral = false; + let parentheses = 0; + let structuredReferenceDepth = 0; + for (let i = 0; i < formula.length;) { + const character = formula[i]; + if (character === '"') { + result.push(character); + if (stringLiteral && formula[i + 1] === '"') { + result.push(formula[i + 1]); + i += 2; + continue; + } + stringLiteral = !stringLiteral; + ++i; + continue; + } + if (!stringLiteral) { + let apostrophes = 0; + if (structuredReferenceDepth && (character === "[" || character === "]")) { + for (let j = i - 1; formula[j] === "'"; --j) ++apostrophes; + } + const escapedBracket = apostrophes % 2 === 1; + if (character === "[" && !escapedBracket) ++structuredReferenceDepth; + else if (character === "]" && !escapedBracket && --structuredReferenceDepth < 0) return null; + if (!structuredReferenceDepth) { + if (character === "(") ++parentheses; + else if (character === ")" && --parentheses < 0) return null; + const reference = character === "'" + ? quotedSheetReference(formula, i, names) + : formulaFunctionAt(formula, i) || unquotedSheetReference(formula, i, names); + if (character === "'" && !reference) return null; + if (reference) { + result.push(reference.text); + i = reference.end; + continue; + } + } + } + result.push(character); + ++i; + } + return stringLiteral || parentheses || structuredReferenceDepth ? null : result.join(""); +} + +function parsedCellValue(value, formulaNames) { + if (value[0] === "'") return {type: "text", value: value.slice(1)}; + if (value[0] === "=") { + const formula = rewriteFormula(value.slice(1), formulaNames); + // Excel also rejects empty formulas and those over its length limit; keep the stored text. + return formula && formula.trim() && formula.length < MAX_FORMULA_CHARACTERS + ? {type: "formula", value: formula} + : {type: "text", value}; + } + const trimmed = value.trim(); + if (trimmed === "") return {type: "blank", value: ""}; + if (/^(TRUE|FALSE)$/i.test(trimmed)) return {type: "boolean", value: /^true$/i.test(trimmed)}; + if (/^[-+]?\$?[\d,]*\.?\d+%?$/.test(trimmed) && /\d/.test(trimmed)) { + const negative = trimmed.startsWith("-"); + const cleaned = trimmed.replace(/[$,+%-]/g, ""); + let number = Number(cleaned); + if (Number.isFinite(number)) { + if (trimmed.endsWith("%")) number /= 100; + return {type: "number", value: negative ? -number : number}; + } + } + return {type: "text", value}; +} + +function cellXml(cell, formulaNames) { + const style = cell.style ? ` s="${cell.style}"` : ""; + if (cell.value === "") return ``; + const parsed = parsedCellValue(cell.value, formulaNames); + if (parsed.type === "blank") return ``; + if (parsed.type === "formula") return `${formulaXml(parsed.value)}`; + if (parsed.type === "boolean") return `${parsed.value ? 1 : 0}`; + if (parsed.type === "number") return `${String(parsed.value)}`; + return `${spreadsheetXml(parsed.value)}`; +} + +function frozenPane(sheet) { + const rows = sheet.frozenRows; + const columns = sheet.frozenColumns; + if (!rows && !columns) return ""; + const attributes = []; + if (columns) attributes.push(`xSplit="${columns}"`); + if (rows) attributes.push(`ySplit="${rows}"`); + attributes.push(`topLeftCell="${columnName(columns + 1)}${rows + 1}"`); + attributes.push(`activePane="${rows && columns ? "bottomRight" : rows ? "bottomLeft" : "topRight"}"`); + attributes.push('state="frozen"'); + return ``; +} + +function worksheetDimension(cells) { + if (!cells.length) return "A1"; + let minRow = MAX_ROWS, minColumn = MAX_COLUMNS, maxRow = 1, maxColumn = 1; + for (const cell of cells) { + minRow = Math.min(minRow, cell.row); + minColumn = Math.min(minColumn, cell.column); + maxRow = Math.max(maxRow, cell.row); + maxColumn = Math.max(maxColumn, cell.column); + } + const first = columnName(minColumn) + minRow; + const last = columnName(maxColumn) + maxRow; + return first === last ? first : first + ":" + last; +} + +function* worksheetXml(sheet, formulaNames) { + yield ``; + yield ``; + yield `${frozenPane(sheet)}`; + yield ``; + if (sheet.columnWidths.length) { + yield ""; + for (const width of sheet.columnWidths) { + yield ``; + } + yield ""; + } + yield ""; + let cellIndex = 0; + let heightIndex = 0; + while (cellIndex < sheet.cells.length || heightIndex < sheet.rowHeights.length) { + const cellRow = sheet.cells[cellIndex]?.row ?? Infinity; + const heightRow = (sheet.rowHeights[heightIndex]?.index ?? Infinity) + 1; + const row = Math.min(cellRow, heightRow); + const height = heightRow === row ? sheet.rowHeights[heightIndex++] : null; + yield ``; + while (sheet.cells[cellIndex]?.row === row) yield cellXml(sheet.cells[cellIndex++], formulaNames); + yield ""; + } + yield ""; +} + +function* stylesXml(styles) { + yield ``; + if (styles.numberFormats.length) { + yield ``; + for (const format of styles.numberFormats) yield ``; + yield ""; + } + yield ``; + for (const font of styles.fonts) { + yield ""; + if (font.bold) yield ""; + if (font.italic) yield ""; + if (font.underline) yield ""; + if (font.strike) yield ""; + yield ``; + if (font.color) yield ``; + yield ''; + } + yield ""; + yield ``; + for (let i = 2; i < styles.fills.length; ++i) { + yield ``; + } + yield ""; + yield ''; + yield ``; + for (const format of styles.cellFormats) { + const alignment = styles.alignments[format.alignmentId]; + let attributes = `numFmtId="${format.numberFormatId}" fontId="${format.fontId}" fillId="${format.fillId}" borderId="0" xfId="0"`; + if (format.numberFormatId) attributes += ' applyNumberFormat="1"'; + if (format.fontId) attributes += ' applyFont="1"'; + if (format.fillId) attributes += ' applyFill="1"'; + if (alignment) attributes += ' applyAlignment="1"'; + if (!alignment) { + yield ``; + continue; + } + const alignmentAttributes = []; + if (alignment.horizontal) alignmentAttributes.push(`horizontal="${alignment.horizontal}"`); + if (alignment.wrap) alignmentAttributes.push('wrapText="1"'); + yield ``; + } + yield ''; +} + +function contentTypes(sheetCount) { + let xml = ''; + xml += ''; + xml += ''; + xml += ''; + xml += ''; + xml += ''; + for (let i = 1; i <= sheetCount; ++i) { + xml += ``; + } + return xml + ""; +} + +function workbookXml(sheets) { + let xml = ``; + xml += ""; + for (let i = 0; i < sheets.length; ++i) { + xml += ``; + } + // Formulas are written without cached results, so ask for one full recalculation on open. + return xml + ''; +} + +function workbookRelationships(sheetCount) { + let xml = ``; + for (let i = 1; i <= sheetCount; ++i) { + xml += ``; + } + return xml + ``; +} + +/** Streams `document` (a complete `Gadget.getDocument()` snapshot) as an XLSX workbook. */ +export function workbookToXlsx(document) { + const {sheets, styles, formulaNames} = prepareWorkbook(document); + const entries = [ + {name: "[Content_Types].xml", data: contentTypes(sheets.length)}, + {name: "_rels/.rels", data: ``}, + {name: "xl/workbook.xml", data: workbookXml(sheets)}, + {name: "xl/_rels/workbook.xml.rels", data: workbookRelationships(sheets.length)}, + {name: "xl/styles.xml", data: textStream(stylesXml(styles))}, + ...sheets.map((sheet, i) => ({ + name: `xl/worksheets/sheet${i + 1}.xml`, + data: textStream(worksheetXml(sheet, formulaNames)), + })), + ]; + return createZip(entries); +} diff --git a/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js b/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js new file mode 100644 index 0000000000..684caab4d7 --- /dev/null +++ b/packages/workshop-backend/format-blueprints/workspace-sheets/files/zip.js @@ -0,0 +1,150 @@ +// Minimal streaming ZIP writer: DEFLATE entries with data descriptors, so +// sizes and CRCs are written after each entry has streamed through. +// +// Only the 32-bit ZIP format is implemented. The platform caps exports well +// below 4 GiB, so the ZIP64 thresholds are unreachable here. + +const encoder = new TextEncoder(); +const UTF8_DATA_DESCRIPTOR_FLAGS = 0x0808; +const DEFLATE_METHOD = 8; +const DOS_TIME = 0; +const DOS_DATE = 33; // 1980-01-01 + +const CRC32_TABLE = new Uint32Array(256); +for (let i = 0; i < CRC32_TABLE.length; ++i) { + let value = i; + for (let bit = 0; bit < 8; ++bit) { + value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + CRC32_TABLE[i] = value >>> 0; +} + +export function crc32(bytes, previous = 0) { + let value = (previous ^ 0xffffffff) >>> 0; + for (let i = 0; i < bytes.length; ++i) value = CRC32_TABLE[(value ^ bytes[i]) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function record(size, write) { + const bytes = new Uint8Array(size); + write(new DataView(bytes.buffer)); + return bytes; +} + +function localHeader(nameLength) { + return record(30, (view) => { + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, UTF8_DATA_DESCRIPTOR_FLAGS, true); + view.setUint16(8, DEFLATE_METHOD, true); + view.setUint16(10, DOS_TIME, true); + view.setUint16(12, DOS_DATE, true); + view.setUint16(26, nameLength, true); + }); +} + +function dataDescriptor(crc, compressedSize, uncompressedSize) { + return record(16, (view) => { + view.setUint32(0, 0x08074b50, true); + view.setUint32(4, crc, true); + view.setUint32(8, compressedSize, true); + view.setUint32(12, uncompressedSize, true); + }); +} + +function centralHeader(entry) { + return record(46, (view) => { + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, UTF8_DATA_DESCRIPTOR_FLAGS, true); + view.setUint16(10, DEFLATE_METHOD, true); + view.setUint16(12, DOS_TIME, true); + view.setUint16(14, DOS_DATE, true); + view.setUint32(16, entry.crc, true); + view.setUint32(20, entry.compressedSize, true); + view.setUint32(24, entry.uncompressedSize, true); + view.setUint16(28, entry.name.length, true); + view.setUint32(42, entry.localOffset, true); + }); +} + +function endOfCentralDirectory(entryCount, centralSize, centralOffset) { + return record(22, (view) => { + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, entryCount, true); + view.setUint16(10, entryCount, true); + view.setUint32(12, centralSize, true); + view.setUint32(16, centralOffset, true); + }); +} + +function byteStream(data) { + if (typeof data !== "string") return data; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(data)); + controller.close(); + }, + }); +} + +async function* generateZip(entries) { + const centralEntries = []; + let offset = 0; + const emit = (bytes) => { + offset += bytes.byteLength; + return bytes; + }; + + for (const {name: rawName, data} of entries) { + const name = encoder.encode(rawName); + const localOffset = offset; + yield emit(localHeader(name.byteLength)); + yield emit(name); + + let crc = 0; + let compressedSize = 0; + let uncompressedSize = 0; + const measured = byteStream(data).pipeThrough(new TransformStream({ + transform(chunk, controller) { + uncompressedSize += chunk.byteLength; + crc = crc32(chunk, crc); + controller.enqueue(chunk); + }, + })); + for await (const chunk of measured.pipeThrough(new CompressionStream("deflate-raw"))) { + compressedSize += chunk.byteLength; + yield emit(chunk); + } + + yield emit(dataDescriptor(crc, compressedSize, uncompressedSize)); + centralEntries.push({name, crc, compressedSize, uncompressedSize, localOffset}); + } + + const centralOffset = offset; + for (const entry of centralEntries) { + yield emit(centralHeader(entry)); + yield emit(entry.name); + } + yield emit(endOfCentralDirectory(centralEntries.length, offset - centralOffset, centralOffset)); +} + +/** + * Streams a ZIP archive of `entries`, each `{name, data}` where `data` is a + * string or a `ReadableStream`. Entries are compressed one at a + * time, in order, as the returned stream is read. + */ +export function createZip(entries) { + const iterator = generateZip(entries); + return new ReadableStream({ + async pull(controller) { + const result = await iterator.next(); + if (result.done) controller.close(); + else controller.enqueue(result.value); + }, + cancel(reason) { + return iterator.return(reason); + }, + }); +} From 54d5d8b0beaec96500ed6fd19281a282702a82f4 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Tue, 8 Sep 2026 16:57:48 -0500 Subject: [PATCH 04/15] feat(gatekeeper-kit): replayable runs, declared action fences, and a conformance consumer (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gatekeeper-kit): replayable runs with account-adjudicated expiry CredentialSource.run gains a replayable option: a credential rejection is retried once with credentials minted through a refreshCredentials channel, and only a rejection of those reports expiry — so derived-bearer ports stop reporting routine stale bearers as grant death. The flag without a channel throws at the call. The refresh is observed, never adopted: plain reads stay the snapshot's only writer, which removes the second concurrent writer earlier revisions fenced around. noteCredentialsExpired now returns the account's verdict on the reported identity — an explicit false resolves as the fixed retry message with the cache authority dropped; anything else (lost answers included) fails closed as accepted. The verdict is asked first, then clear and fence land as one synchronous transition, and a reconnect crossing the refresh fences any authority not adopted past it. Replays coalesce per rejected read via SingleFlight's new object keys. Plan §4.6/§4.13/§5.6/§5.8/§6 reconciled with the reduced model. * fix(gatekeeper-kit): enforce API invariants and document usage * fix(gatekeeper-kit)!: close the adoption gaps a consumer audit found A second audit read the kit as a new gatekeeper author would. Five places advertised a safety property the callbacks could not deliver, or narrowed a canonical contract until the high-level path had to be abandoned. - Actions distinguish a terminal failure known to have left no provider effect from one whose outcome is unknown, and the dependency cascade consults the resolution oracle apply already uses instead of retiring dependents whose reference the provider had bound. - `describe` can declare `pushedCommits` again, with the compiler forcing every future `ActionDescription` field into a kit-owned or provider-owned list. - Provider-backed cursors authorize the exact page they return, including one served from the buffer and the terminal answer of a walk that disclosed nothing -- a zero-result search is an existence oracle. - Observer strategies declare whether they can enforce collection ACLs, and the gate refuses a scope a strategy would silently ignore. - `ObservationGate` reaches the git cache, so a gatekeeper returning commit ids no longer needs a raw queue stub. - Journals and caches require a named keyspace, with an explicit legacy opt-in. Carries two changes made alongside it: `KvTtlCache.partitionedBy` now reads a live `cacheAuthority()` that only vouches for credentials the source still stands behind, and `KvScannable.list` gained storage-level page bounds. * test(gatekeeper-kit): add a conformance consumer that assembles the kit Every existing suite tests one leaf. This one builds a gatekeeper from all of them at once against a fake provider -- account Durable Object on CredentialCoordinator and the connect handshake, resource facet on CredentialSource, ObservationGate, defineActions, KvTtlCache and TokenCursor -- because the contracts a new consumer trips over only appear in assembly. It runs in workerd with real Durable Objects, so persisted stubs, RPC boundaries and storage are genuine. Sixteen tests cover OAuth completion and stale-attempt refusal, repeated credential rotation, tracked child ACLs, a zero-result cursor, a non-idempotent action whose provider outcome is unknown, provisional dependencies, git-cache access, cache repartition on reconnect, action fencing and journal namespace isolation. It found a defect in the API it exercises: `ObservationGate.getGitCache()` was annotated `Promise`, so `using` on the returned caller-owned stub did not compile although the doc told consumers to use it. The return type is now the queue stub own, which keeps it `Disposable`. Also records two things a consumer cannot do, both learned the hard way here: a plain object passed as the approval queue crosses RPC as call-scoped stubs that are disposed when the call returns, and the kit stateful objects are Durable-Object-local by construction -- `perStorage` keys coordination on storage-object identity, so a journal or gate that crossed a boundary would have lost that even if it serialized. * feat(gatekeeper-kit)!: declare action fences, fence connect completion, rename sets to collections Three API reshapes that are free now and impossible after the first adopter. An action fence was an optional argument at every call site, so omitting one was invisible: the gatekeeper works, its tests pass, and an action approved under one provider account later applies under the next. `defineActions` now requires a set-level `fence` policy with per-kind `fenceOverrides`, and `submit` refuses a fenced kind staged without one. The policy is `"authority" | "none"` rather than naming a connection: the journal never interprets the value, so a provider whose actions should survive re-authorization of the same account fences on a stable account id instead. The kit still cannot capture the fence -- it must ride the staging operation own `CredentialRead`, since a second read taken inside `submit` could land after a reconnect. `claimOAuth` consumes its nonce before the provider token exchange, so a revoke or newer reconnect can land while that exchange is in flight and be overwritten by the older completion. `connect(credentials, { ifGeneration })` compares the connection the attempt started under -- captured through `advanceToOAuth` metadata, which already carries it -- and throws `ConnectionSupersededError` rather than storing a mint the account has moved past. Opt-in, because a flow with no round trip has no window to fence. A "set" never said set of what. The concept is a provider-side access-controlled grouping -- a Confluence space, a Jira project, a repo -- whose ACL governs the items a read returned. `trackedCollectionObservers`, `hasCollectionAccess` and the rest follow. Storage is untouched: `collectionPrefix` still defaults to `"observed:"`, and the kit observers module has no importer outside the kit. * fix(gatekeeper-kit): lease the observation gate and close the review findings A cursor is returned to the gadget and walked later, so it outlives the call that made it. Built on the session own gate, the first `next()` after the session released its stub failed -- and what failed was the authorization, not the data. `ObservationGate.lease()` opens a second gate over its own duplicate of the queue; both share the binding strategy, so exclusions stay one decision. Making the conformance consumer use it surfaced a constraint worth recording: `dup` is reserved over RPC, so only a real `RpcStub` can lease, not a service binding. Review follow-ups: - An unknown observation outcome latches `observer-withheld` and deletes its marker at once. Compaction only ever ran from `addObserver`, so a binding that admits nobody accumulated one durable key per ambiguous failure -- and since the overseer marks no refusal, that is the default path. - A cache entry is dated from when `load()` resolved, not from when the post-load fence read returned; that read is a live account call that can refresh credentials, and dating from its completion extended the caller TTL. - `ActionOutcomeUnknownError` non-replay rests on `claimBeforeApply`, and the doc promised it unconditionally. It now states the precondition, and the kit logs when the guarantee was unavailable. - Conformance handlers compare the action fence against the read their provider call runs under, closing the window apply entry check cannot; the connect race reproduces a revoke landing inside the exchange rather than before it. `plans/gatekeeper-kit.md` is reconciled: its Status claimed §4 matched the shipped signatures while still naming `trackedSetObservers`, `authority()` and an optional cache `options`, and arguing for the no-extra-round-trip cache partitioning the live fence replaced. * fix(gatekeeper-kit)!: remember grant death, narrow the observation gate Two boundary corrections a consumer audit demonstrated. The account forgot a provider-confirmed grant death as soon as the call that found it returned. `#expired` only notified, so `snapshot()` kept serving the same dead grant to every other facet -- and a facet whose cache authority was still vouched for served warm hits from it. The coordinator now records the dead grant's identity fence under `credentials:expired` and refuses it from `#connected()`, so `fresh()` and `rotate()` both fail before any provider work, including while the access token is still inside its own expiry window. The fence is identity-checked on both sides: a stale failure never buries a successor, a mint that lands after the burial goes to `discardMint` rather than being committed, and `#overtaken` will not hand back a successor that is itself marked. The grant stays stored, so account-owned revoke keeps its material and a failed expiry notification is still retried by a later read -- death is durable, delivery is not. `ObservationGate` demanded `RpcStub` while the canonical read-only capability is `RpcStub`, which catalog and slash-command handlers actually receive; passing one was a TS2345, and calling `authorizeObservation` raw to get around it would skip the strategy's derived exclusions. Everything the gate needs -- authorize, git cache, dup, dispose -- is on the authorizer, so it takes that instead. Only the pass-through `actions` getter needed the wider surface, and it is gone: a session already holds the whole approval queue, so it owns that stub and gives the gate `queue.dup()`. BREAKING CHANGE: `ObservationGate` now takes `RpcStub`. The exported `ActionQueue` type and the `ObservationGate.actions` getter are removed; stage actions through the session's own approval-queue stub. --- packages/gatekeeper-kit/AGENTS.md | 36 + packages/gatekeeper-kit/README.md | 99 + packages/gatekeeper-kit/USAGE.md | 575 +++++ .../__tests__/action-files.test.ts | 4 +- .../gatekeeper-kit/__tests__/actions.test.ts | 740 ++++++- .../__tests__/auth-retry.test.ts | 13 +- .../gatekeeper-kit/__tests__/cache.test.ts | 289 ++- .../__tests__/credentials.test.ts | 1849 +++++++++++++++-- packages/gatekeeper-kit/__tests__/env.d.ts | 5 +- packages/gatekeeper-kit/__tests__/fake-kv.ts | 14 +- .../__tests__/observers.test.ts | 418 +++- .../__tests__/preview-oauth.test.ts | 64 +- .../__tests__/response-body.test.ts | 4 +- .../__tests__/simulation.test.ts | 30 + .../__tests__/workerd/conformance.test.ts | 468 +++++ .../workerd/conformance/gatekeeper.ts | 567 +++++ .../__tests__/workerd/conformance/provider.ts | 170 ++ .../__tests__/workerd/cursors.test.ts | 382 +++- .../workerd/observer-storage.test.ts | 5 +- .../__tests__/workerd/worker.ts | 21 +- packages/gatekeeper-kit/src/action-journal.ts | 271 ++- packages/gatekeeper-kit/src/actions.ts | 334 ++- packages/gatekeeper-kit/src/auth-retry.ts | 11 +- packages/gatekeeper-kit/src/cache.ts | 108 +- .../gatekeeper-kit/src/connect-handshake.ts | 12 +- .../gatekeeper-kit/src/credential-expiry.ts | 4 +- packages/gatekeeper-kit/src/credentials.ts | 776 ++++++- packages/gatekeeper-kit/src/cursors.ts | 126 +- packages/gatekeeper-kit/src/endpoint.ts | 5 +- packages/gatekeeper-kit/src/kv.ts | 6 +- packages/gatekeeper-kit/src/observer-keys.ts | 38 + .../gatekeeper-kit/src/observer-tracker.ts | 315 ++- packages/gatekeeper-kit/src/observers.ts | 182 +- packages/gatekeeper-kit/src/positive-int.ts | 8 +- packages/gatekeeper-kit/src/preview-oauth.ts | 45 +- packages/gatekeeper-kit/src/simulation.ts | 15 +- packages/gatekeeper-kit/src/single-flight.ts | 20 +- .../gatekeeper-kit/vitest.worker.config.ts | 6 +- plans/gatekeeper-kit.md | 1058 +++++++--- 39 files changed, 7861 insertions(+), 1232 deletions(-) create mode 100644 packages/gatekeeper-kit/AGENTS.md create mode 100644 packages/gatekeeper-kit/README.md create mode 100644 packages/gatekeeper-kit/USAGE.md create mode 100644 packages/gatekeeper-kit/__tests__/workerd/conformance.test.ts create mode 100644 packages/gatekeeper-kit/__tests__/workerd/conformance/gatekeeper.ts create mode 100644 packages/gatekeeper-kit/__tests__/workerd/conformance/provider.ts create mode 100644 packages/gatekeeper-kit/src/observer-keys.ts diff --git a/packages/gatekeeper-kit/AGENTS.md b/packages/gatekeeper-kit/AGENTS.md new file mode 100644 index 0000000000..fc8e846e3d --- /dev/null +++ b/packages/gatekeeper-kit/AGENTS.md @@ -0,0 +1,36 @@ +# Gatekeeper Kit contributor notes + +`@gadgets/gatekeeper-kit` is a library, not a deployable Worker. Do not add `wrangler.jsonc`; its +presence makes release tooling treat this package as a gatekeeper deployment. + +## Package boundaries + +- Only Layer 1 leaf modules are shipped. Layer 2 remains a proposal in + [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). +- Keep leaf modules independently usable. Do not make one depend on a future assembly layer. +- Accept the narrowest structural KV surface a module needs. Pass stable `ctx.storage.kv` objects to + modules that coordinate work by storage identity. +- Treat shipped storage keys and prefixes as compatibility. Use existing key and prefix options when + porting a gatekeeper with a different layout. +- Public modules are explicit subpath exports in [`package.json`](package.json). Document new public + symbols with JSDoc and add the subpath to the README inventory. + +## Documentation ownership + +- [`README.md`](README.md) maps the package and its modules. +- [`USAGE.md`](USAGE.md) owns cross-module integration guidance and operational sharp edges. +- Exported-symbol JSDoc owns the exact API contract visible in editors. +- [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md) is the design record, including the + unshipped Layer 2 proposal. Do not duplicate that proposal into current usage documentation. + +## Verification + +Use Node tests for pure logic. Use the workerd project for persisted RPC stubs, `RpcTarget`, +Durable Object behavior, and `crypto.subtle.timingSafeEqual`. + +From the repository root: + +```sh +pnpm --filter @gadgets/gatekeeper-kit test:run +vp run -F @gadgets/gatekeeper-kit build +``` diff --git a/packages/gatekeeper-kit/README.md b/packages/gatekeeper-kit/README.md new file mode 100644 index 0000000000..93c4de8314 --- /dev/null +++ b/packages/gatekeeper-kit/README.md @@ -0,0 +1,99 @@ +# `@gadgets/gatekeeper-kit` + +Shared building blocks for gatekeeper connect flows, credentials, actions, observations, cursors, +caching, and simulation. These modules replace security-sensitive plumbing that gatekeepers had +implemented separately. + +The kit provides a pragmatic baseline for common gatekeeper behavior. Provider-specific or esoteric +behavior can use the canonical TypeScript interfaces directly while retaining whichever leaf +modules still fit. + +## Current scope + +Only Layer 1 is shipped: independent leaf modules exposed through package subpaths. Import them à la +carte; none requires a gatekeeper assembly. + +Layer 2, including `KitUserAccountBase`, `KitVendorBase`, and `KitGatekeeperBase`, remains a proposal +in [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). No gatekeeper consumes it. + +The code and tests define the shipped behavior. The plan records the design and the unshipped +proposal. + +## Responsibility boundary + +The kit owns provider-independent mechanisms inside each imported module. The gatekeeper owns +provider facts, policy, and the assembly around those modules. Importing a leaf does not transfer +the duties in the right-hand column. + +| Concern | Kit owns | Gatekeeper owns | +| --- | --- | --- | +| Assembly and lifetime | Independent leaf contracts; no Layer 2 assembly is shipped. | Worker, account, and resource-facet RPC surfaces; stub disposal; keeping stateful kit objects stable for one Durable Object activation. | +| Connect | Nonce generation and comparison, the two-stage handshake, hardened HTML, and browser mutation guards. | Routes, authorization parameters, provider exchange, completion ordering, persistence, rollback, and reconnect or revoke races. | +| Preview OAuth | Signed state, stable-to-preview callback relay, and return-host validation. | Deployment configuration, provider callback parameters, issuer checks, and retaining the exact redirect URI for code exchange. | +| Credentials | Atomic credential records, refresh coalescing, identity and connection generations, replay, and rejection adjudication. | Grant shape and projection, token exchange, provider error classification, refresh-field merging, revocation, and display-safe errors. | +| Credential expiry and simple auth retry | Durable deduplication of expiry notifications and one-refresh/one-replay helpers. | Deciding what proves grant expiry, provider refresh and revoke calls, and choosing the coordinator flow versus the standalone retry helper. | +| Actions | Durable submission and resolution, serialization, journaling, retention mechanics, connection fences, and dependency tracking. | Approval text, provider calls, idempotency and reconciliation, action-specific simulation, revert semantics, and retention policy. | +| Action files | Bounded chunk storage, integrity verification, aggregate accounting, deletion, and orphan-pruning mechanics. | Byte limits and key prefixes, keeping references in action records, describing the same bytes that will be applied, and releasing files with their records. | +| Simulation | Ordered pending-action views, pure replay with explicit incomplete results, and durable provisional-ID allocation and binding. | Target extraction, state transitions, unsupported-effect policy, provider ID syntax, and projecting pending effects onto every affected read. | +| Observations | Admission strategies, tracked-collection fencing, exclusion derivation, and the guarded authorization call. | Calling the gate for every read, truthful escaped descriptions, choosing the matching strategy and scope, ACL oracles, and collection canonicalization. | +| Cursors | Serialized walks, buffering, common page/offset/token continuation, filtering hooks, and per-page authorization callbacks. | Provider page adapters, page limits and termination semantics, resource lifetime, and the observation description for each returned page. | +| Cache | Authority partitioning, stale-fill fencing, TTL storage, single-flight loads, and invalidation. | Cache families, names and keys, authority dimensions, TTLs, value projection, and deciding which reads are safe to cache. | +| HTTP endpoints and responses | Operator-supplied endpoint normalization, common no-access probes, and byte-capped text decoding. | Host allowlists, redirect and header policy, response schemas, provider errors, and binary or streaming limits. | + +When a provider does not fit a leaf, implement the canonical TypeScript interface directly and keep +the other leaves that still fit. Bypassing a leaf also means owning its guarantees for that concern; +do not call around a stateful module while relying on its journal, fence, or lifetime elsewhere. + +## Start here + +- For an OAuth-shaped provider, start with the + [credentials guide](USAGE.md#credentials). It covers account-side storage, consumer-side RPC, + refresh, replay, expiry, and action fences. +- For provider writes, use [`./actions`](#module-inventory) and read + [Actions and files](USAGE.md#actions-and-files). +- For every gatekeeper's observer methods, select a strategy from `./observers` and read + [Observations](USAGE.md#observations). +- For details attached to one class, function, option, or error, read that export's JSDoc. + +Import from the narrow subpath: + +```ts +import { + CredentialCoordinator, + CredentialSource, +} from "@gadgets/gatekeeper-kit/credentials"; +``` + +## Module inventory + +| Subpath | Purpose | Use it when | +| --- | --- | --- | +| `./connect-nonce` | Nonce generation, expiry, and constant-time comparison. | A connect flow mints or checks its own nonce. The handshake and credential modules already use it. | +| `./connect-handshake` | Two-stage `initiation` to `oauth` nonce storage. | A connect link or form redirects through an OAuth provider. | +| `./connect-pages` | Hardened connect HTML, escaping, and browser mutation guards. | A gatekeeper serves HTML from its own origin. | +| `./credentials` | Account-side `CredentialCoordinator` and consumer-side `CredentialSource`. | An OAuth-shaped provider stores, refreshes, or rejects credentials. | +| `./credential-expiry` | Durable, deduplicated `credentialsExpired()` notification. | An account has a Workshop connect callback to notify. | +| `./auth-retry` | One refresh and replay without account adjudication. | A token flow has no `CredentialSource`; otherwise use `CredentialSource.run()`. | +| `./cache` | Authority-partitioned Durable Object TTL caching. | Provider reads repeat and reconnects must fence stale fills. | +| `./cursors` | Array, page-number, offset, and continuation-token cursors. | A session returns more rows than one RPC reply should carry. | +| `./actions` | Action declaration, approval, application, retention, and journaling. | An operation has an externally visible side effect. | +| `./action-files` | Bounded, integrity-checked action-file storage. | A queued action carries file bytes. Store only its `ActionFileReference` in the action. | +| `./simulation` | Pending-action replay and provisional-ID mapping. | An action continues with simulation and later reads must include its projected effect. | +| `./observers` | Observer admission strategies and per-read authorization. | A gatekeeper implements its required observer methods. | +| `./preview-oauth` | Signed OAuth state and stable-to-preview callback relay. | Preview Workers share one callback registered with the OAuth provider. | +| `./endpoint` | User-supplied provider endpoint normalization. | A user enters a self-hosted provider URL. | +| `./http-errors` | HTTP access-error classification and ACL probes. | A verifier distinguishes no access from provider failure. | +| `./response-body` | Strict byte-capped response decoding. | A gatekeeper reads any provider response body. | + +## Internal modules + +The package does not export `kv`, `positive-int`, `per-storage`, `serial-queue`, `single-flight`, +`action-journal`, or `observer-tracker`. The last two are re-exported through `./actions` and +`./observers`. + +## More documentation + +- [`USAGE.md`](USAGE.md): integration sequencing, storage, bounds, and operational sharp edges. +- Exported-symbol JSDoc: exact API contracts and examples. +- [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md): design record and Layer 2 proposal. +- [`AGENTS.md`](AGENTS.md): package-specific contributor constraints and verification commands. diff --git a/packages/gatekeeper-kit/USAGE.md b/packages/gatekeeper-kit/USAGE.md new file mode 100644 index 0000000000..405d52006f --- /dev/null +++ b/packages/gatekeeper-kit/USAGE.md @@ -0,0 +1,575 @@ +# Using `@gadgets/gatekeeper-kit` + +The kit exposes independent modules through package subpaths. Import only the pieces the gatekeeper +needs: + +```ts +import { + CredentialCoordinator, + CredentialSource, +} from "@gadgets/gatekeeper-kit/credentials"; +``` + +The exported symbols carry their exact contracts in JSDoc. This guide covers the choices and +sequencing that span more than one symbol. + +## Credentials + +An OAuth-shaped provider needs both halves of the credential API: + +- `CredentialCoordinator` owns storage, migration, refresh, and rejection adjudication in the + account Durable Object. +- `CredentialSource` fetches those credentials over RPC and runs provider calls in a resource + facet. + +The `CredentialSource over a CredentialCoordinator` suite in +[`__tests__/credentials.test.ts`](__tests__/credentials.test.ts) is the executable reference. + +### 1. Create the coordinator in the account Durable Object + +Use the stable `ctx.storage.kv` object so refreshes coalesce across coordinator instances. +`discardMint` is deliberately absent: whether a fenced-out mint can be revoked without killing the +surviving connection is provider-specific — see "Revoke discarded token rotations" below. + +```ts +#creds = new CredentialCoordinator(this.ctx.storage.kv, { + expiresAt: grant => grant.expiresAt, + legacyKeys: ["accessToken", "refreshToken"], + upgrade: kv => readLegacyGrant(kv), + vendorId: VENDOR_ID, +}); +``` + +`legacyKeys` is the deletion set, not only the migration input. List every key the old layout owned, +including expiry, scope, endpoint, and refresh-token keys. `clear()` deletes exactly this set, so an +omitted key leaves credential material behind after disconnect. + +### 2. Expose the account RPC methods + +Both methods stay thin because the coordinator owns the atomic credential, identity, and generation +triple, refresh fencing, and rejection verdicts: + +```ts +async getCredentials(): Promise> { + const { creds, identity, generation } = await this.#creds.snapshot( + grant => refreshAtProvider(grant), + { notify: () => this.#notify() }, + ); + return { + creds: { token: creds.token, expiresAt: creds.expiresAt }, + identity, + generation, + }; +} + +reportCredentialsRejected(identity: string) { + return this.#creds.adjudicateRejection(identity, { + refresh: grant => refreshAtProvider(grant), + notify: () => this.#notify(), + }); +} + +#notify() { + const callback = this.ctx.storage.kv + .get>("callback"); + return notifyCredentialsExpiredOnce( + this.ctx.storage.kv, + callback, + VENDOR_ID, + ); +} +``` + +Project credentials before returning them. Refresh material must not cross the account RPC +boundary. + +`refreshAtProvider` owns a classification the kit cannot make: throw `CredentialsExpiredError` only +when the provider proves the *grant* is dead — `invalid_grant` from the token endpoint, a revoked +refresh token, or provider-specific evidence of the same. Let transport, malformed-response, and +5xx failures travel unchanged, and do not treat a bare `invalid_token`: that is RFC 6750 for the +presented access token, which a refresh recovers. Treating either an outage or a recoverable token +rejection as grant death destroys healthy authority and prompts an unnecessary reconnect. + +It also owes the *complete* canonical record, not the provider's response. Providers routinely omit +values that did not change — an unchanged rotating refresh token, granted scopes, provider metadata +— and the coordinator replaces the stored record wholesale, so anything absent is lost and the next +refresh fails after the first successful rotation: + +```ts +const response = await exchangeRefreshToken(grant); +return { ...grant, ...response, refreshToken: response.refreshToken ?? grant.refreshToken }; +``` + +Omit `adjudicateRejection`'s `refresh` callback when rejection of a current credential proves the +whole grant is dead. A heal cannot recover that provider model and would suppress the expiry +notification. + +A provider-confirmed death is recorded against the grant's identity fence, so every later read — +in this facet or any other over the same storage — refuses it until a reconnect replaces it, even +while its access token is still inside its own expiry window. The grant itself stays stored, so +account-owned revoke keeps its material and a failed expiry notification can still be retried by +a later read. + +Every credential replacement re-arms the expiry latch. This includes `connect()`, successful +refresh, and rejection healing. A legacy-layout migration does not re-arm it because it replaces no +credentials. `clearCredentialExpiryLatch` remains available for accounts that manage credentials +without `CredentialCoordinator`. + +`claimOAuth()` consumes its nonce *before* the provider token exchange, so a revoke or a newer +reconnect can land while that exchange is in flight. Unfenced, the older completion overwrites it. +Capture the connection when the attempt starts and hand it back at the end: + +```ts +// Starting the attempt: `advanceToOAuth` carries arbitrary metadata through the callback. +const state = advanceToOAuth(kv, linkNonce, Date.now(), + { startedUnder: this.#creds.connectionGeneration() }); +if (state === null) throw new Error("This connect link has expired. Start again."); + +// Completing it. Both handshake calls return null for an expired or replayed nonce, and the claim +// is checked before the exchange: minting first would leave a live grant nothing here can revoke. +const claim = claimOAuth<{ startedUnder: string }>(kv, oauthNonce, Date.now()); +if (claim === null) throw new Error("This connect attempt has expired. Start again."); + +const grant = await exchangeCode(code); +try { + this.#creds.connect(grant, { ifGeneration: claim.startedUnder }); +} catch (error) { + if (!isConnectionSuperseded(error)) throw error; + // Never stored, so this mint is yours to dispose — but only where revoking one token cannot + // revoke the whole grant; see "Revoke discarded token rotations" below. + await revokeAtProvider(grant); + // Either a `clear()` or a newer winning `connect()` moves the generation, so report the change + // rather than asserting which one happened. + throw new Error("This account's connection changed while connecting. Start again."); +} +``` + +Without `ifGeneration`, `connect()` writes unconditionally. Fencing is opt-in because a flow with +no round trip — a pasted token, a form submission — has no window to fence and would have to +invent a generation to pass. + +This closes the window between the claim and the write. It does not order two attempts that both +reach the exchange: the handshake holds one nonce, so a second attempt reaching `advanceToOAuth` +invalidates the first's callback, but an attempt that already claimed will still win if it +completes first. + +### 3. Run facet calls through `CredentialSource` + +```ts +#creds = new CredentialSource({ + account: () => this.env.ACCOUNT.get(this.accountId), + isAuthError: error => + error instanceof VendorApiError && error.status === 401, + expiredMessage: "Reconnect the Vendor account in the Workshop.", +}); + +listProjects() { + return this.#creds.run( + grant => this.#api.listProjects(grant), + { replayable: true }, + ); +} +``` + +`isAuthError` classifies credential rejection only. Do not classify a per-resource 403 or 404 as an +authentication error; doing so can retire a healthy account. + +Set `replayable: true` only when the operation may execute twice. Re-entry can repeat provider calls +that succeeded before a later call rejected the credential. Without that flag, stale rejection +surfaces as `CredentialsChangedError` instead. + +### 4. Handle the credential errors + +Handle two credential errors: + +- `isCredentialsChanged(error)` means the operation used stale credentials. Re-enter a replay-safe + operation or surface the error when replay is unsafe. +- `isCredentialsExpired(error)` means the provider proved the grant is dead. Tell the user to + reconnect. Workshop notification was attempted separately and may have failed. + +Let every other error travel unchanged, including account RPC failures. An unreachable account is +not an expired grant. + +### 5. Revoke discarded token rotations + +A provider that rotates refresh tokens should implement `discardMint`. A reconnect or revoke can win +while refresh is in flight, leaving the completed mint fenced out of storage. Revoke that grant at +the provider so no live credential chain remains without a stored handle. + +Do this only where revoking the discarded mint cannot invalidate the grant the surviving connection +uses. RFC 7009 lets a provider treat revoking one refresh token as revoking the whole authorization +grant, so where a reconnect reuses one grant per (user, client) the disposal kills the connection +that just won. For such a provider omit `discardMint` and order refresh against connect and clear in +the account itself — the kit supplies no primitive for that. + +Errors from `discardMint` are logged and do not replace the winning operation. It cannot recover a +crash between provider rotation and storage; the user must reconnect in that case. + +### 6. Declare each action's fence, and capture it from the operation's own read + +`defineActions` requires a `fence` policy for the whole set, with `fenceOverrides` naming the kinds +that differ. It is required rather than defaulted because an omitted fence is invisible: the +gatekeeper works, its tests pass, and an action approved under one provider account later applies +under the next one. + +```ts +defineActions(definitions, { + fence: "authority", + // Named one at a time, so opting out is always a decision someone made. + fenceOverrides: { pingHealthEndpoint: "none" }, +}); +``` + +An `"authority"` kind must be staged with the authority the operation ran under. For the common +connection fence that is the `CredentialRead` **the staging operation itself ran under** — `CredentialSource.run()` passes it as the operation's second argument, and it is +structurally an `ActionFence`, so `{ fence: read }` works verbatim. `submit` refuses the call +without one, and refuses a fence on a kind declared `"none"`. The kit never interprets the value, so a provider that wants an action to survive re-authorization of the same account stores its own stable account id instead and passes that at apply. + +The read has to be the operation's own. A second `read()` taken inside the submit path can land +after a reconnect and would pin old-connection data to the new connection — which is why the kit +cannot capture the fence for you. + +Apply then compares whatever was staged, by opaque equality. For a connection fence pass +`apply(id, { generation })` from `CredentialSource.read()`; for a custom fence pass that same +stable value instead — a connection generation and an account id can never match, and an action +staged under one and applied under the other fails terminally on every attempt. That is an +entry check, so a reconnect may still land between it and the provider call. A handler that must +not run under a replaced connection compares `ctx.fence` with the `CredentialRead` passed to the +same `run` callback that issues the request. + +## Storage + +### Name the narrowest surface + +Each module accepts the structural KV surface it needs (`KvReadWrite`, `KvMutable`, or +`KvScannable`) instead of `DurableObjectStorage`. The signature records whether the module can read, +write, delete, or scan. It also keeps pure modules testable against a plain object. + +### Pass stable storage objects + +Pass the same `ctx.storage.kv` object on every access. Credential refreshes, expiry notifications, +and observer claim counts key process-local coordination by storage-object identity. Wrapping the +storage for every call defeats coalescing and can spend a single-use refresh token twice. + +That identity requirement is also why the kit's stateful objects are Durable-Object-local. A +journal, gate, cache, coordinator, source, or tracker is built inside the object that owns its +storage and never crosses an RPC boundary — attempting it fails with `DataCloneError`. Expose RPC +methods instead, and return either plain data or a cursor: `ArrayCursor` and the provider-backed +cursors extend `RpcTarget` precisely because they are the one kit type meant to be handed out. + +### Name every keyspace + +`ActionJournal` takes a `namespace` and `KvTtlCache` a `name`; both derive every key from it. Two +journals sharing a keyspace share ids and capacity while each bound action set serializes apply and +reject on its own in-memory queue, so nothing orders their provider calls against each other. Two +caches sharing one serve each other's values for colliding keys, and either one's `invalidateAll()` +clears both. + +### Treat storage layout as compatibility + +Shipped key names and prefixes are compatibility surfaces. Renaming one silently orphans live +records. A port that must keep reading records it already wrote passes `legacyKeys` (journal) or +`legacyUnnamed` (cache) instead of a namespace — mutually exclusive with it, so the unsafe shared +layout is always an explicit choice. `ObserverTrackerOptions` has its own key options for the same +reason. + +### Fake the surface, not the runtime + +A Node test double can be a small object implementing the required KV methods. Transactional tests +can add the synchronous transaction surface: + +```ts +const storage = { + kv, + transactionSync(callback: () => T): T { + return callback(); + }, +}; +``` + +Persisted RPC stubs, `RpcTarget` behavior, and `crypto.subtle.timingSafeEqual` need workerd tests. +Those suites live under [`__tests__/workerd/`](__tests__/workerd/) and load +`@gadgets/scripts/assert-workerd`, so a failed Workers pool cannot pass silently in Node. + +## Caching + +Give each `KvTtlCache` a `name`, which gives it its own keys and generation. + +`partitionedBy` asks the source for a live connection fence on every hit, so a reconnect +repartitions before the next hit rather than at the next provider call. That costs one account +credential read per hit — which may itself run a normal credential refresh — and still avoids the +provider request the entry exists to cache. A disconnected account propagates its own error; a +source that cannot vouch for the credentials bypasses the cache. Compose an authority on the raw +constructor only where it is genuinely local. + +## Actions and files + +Use `defineActions` and `stageAction` for externally visible side effects. They own the +submit, approve, apply, and retire lifecycle, including retryable versus terminal failure, +dependency stranding, and connection fences. + +`retainApplied: true` opts out of retirement: applied records move to a retained tier the kit never +bounds. Enforce the binding's retention policy inside `runExclusive()`: walk storage-bounded pages +with `journal.listRetained({ limit, cursor })`, pass each `nextCursor` back until it is absent, and +call `journal.retire(id)` for each expired record. + +An apply failure has three outcomes, and the handler picks by what it throws. An ordinary error is +retryable: the record returns to pending and the overseer may apply it again, so use it only when a +second attempt is safe. `ActionApplyError` is terminal and asserts the provider effect is **known +absent** — it retires the dependents waiting on references this action was to provide. +`ActionOutcomeUnknownError` is terminal and asserts nothing: use it for a timeout, an aborted +request, or any failure after the provider was reached. That record is never replayed and never +pruned, strands no dependent, and holds a slot until the user rejects it, so the "check the +provider" warning survives. + +`claimBeforeApply` produces that same unknown outcome when an activation dies mid-dispatch. Neither +substitutes for a provider idempotency key derived from the stable `ActionContext.id`, which is +what makes a retry safe in the first place. + +Store action file bytes with `ActionFileStore`. Put only the bounded `ActionFileReference` in the +action payload. Journal records must stay small, and approval text must describe the same bytes that +will be applied. + +Release those bytes yourself — the kit never collects them, and every capture counts against +`maxTotalBytes` until it is deleted. Call `delete(reference)` when the action's record goes away: +on resolution normally, but only when `journal.retire(id)` removes an expired retained record under +`retainApplied: true`, since that record is what a revert reads back. Sweep orphans with +`pruneUnreferenced(referenced, createdBefore)` +before a new capture, passing every handle your pending **and retained** records name, plus a cutoff +old enough to spare a capture whose submission is still in flight. An orphan outlives a rejected +action, a terminal failure, and a capture whose `submit` never landed; without a sweep they +accumulate until the cap refuses every new file-backed action. + +A declaration using `delivery: "continue-with-simulation"` must project pending actions onto later +reads. Use `createSimulationView`, `replaySimulation`, and `ProvisionalIds` for that projection and +for mapping provisional IDs to provider IDs. + +## Observations + +Every session method that returns provider data must await `ObservationGate.authorize()` before it +returns, in this order: fetch, authorize, return. + +```ts +async getPage(id: string) { + const page = await this.#api.page(id); + const title = escapeObservationValue(page.title); + await this.#gate.authorize( + { title: `Page: ${title}`, description: `Read page **${title}**.` }, + { kind: "collections", ids: [page.spaceId] }, + ); + return project(page); +} +``` + +Run every provider-controlled string through `escapeObservationValue()` first. It collapses +newlines and escapes Markdown controls, so a page whose title carries `#` or a line break cannot +forge structure in the text a human approves against. + +Nothing in the kit can enforce this — no code sits between a session method and its return value. +Skipping it fails silently: reads keep working, the Workshop records no observation, and the +strategy's derived `excludeObservers` never reaches the overseer, so owner-only data goes to every +admitted collaborator. Authorizing after the fetch is what makes the description name the bytes +actually disclosed; authorizing before it would describe a read that may still fail. + +`ObservationGate` is the only path to `authorizeObservation`. It takes a duplicate of the stub it +guards and owns that dup, so a session holds two owners — its own approval queue for staging +actions, and the gate over `queue.dup()` — and releases both when the session ends: + +```ts +#queue = queue; +#gate = new ObservationGate(queue.dup(), this.#observers); + +[Symbol.dispose]() { + this.#gate[Symbol.dispose](); + this.#queue[Symbol.dispose](); +} +``` + +The gate only needs `ObservationAuthorizer`, the read-only capability, so a catalog or +slash-command handler — which receives exactly that — constructs one from its own +`authorizer.dup()`. Gate leases (`lease()`) are independent owners in the same way. + +Every gatekeeper must implement the three observer methods, and `GatekeeperUser.getVerifier()` +alongside them — that capability is what `aclObservers` and `trackedCollectionObservers` call to check a +collaborator, and `asVerifier` casts it to the vendor's own interface. Select one strategy: + +- `privateObservers` rejects collaborators. +- `aclObservers` checks baseline resource access when a collaborator is admitted. +- `trackedCollectionObservers` tracks disclosed collections and rechecks each observer for every collection-scoped read. +- `openObservers` admits every observer without consulting the provider. Choose it only where the + data carries no provider-side access distinction, since a collaborator the provider itself would + refuse still observes everything the binding reads. + +`trackedCollectionObservers` persists verifier stubs. Its Worker needs the +`allow_irrevocable_stub_storage` compatibility flag; without it, the first `addObserver` fails with +`DataCloneError`. + +### Baseline access is checked at admission + +`verifyBaseline` and `aclObservers.hasAccess` run when a collaborator is admitted. The overseer +re-admits on every open, so losing Workshop membership is the revocation path. + +Only `trackedCollectionObservers` continuously runs its oracle. It calls `hasCollectionAccess` for every observer +on every collection-scoped read. If the provider can revoke binding-level access independently of Workshop +membership, a `{ kind: "baseline" }` read is insufficient because it consults no oracle. Represent +that disclosure with a synthetic collection ID instead. + +### Scope describes the disclosure + +`ObservationScope` describes what a read reveals: `baseline`, `collections`, or `withholdFromObservers`. + +A **collection** is a provider-side access-controlled grouping — a Confluence space, a Jira project, a +GitHub repo — whose ACL governs the items the read returned. Pass the ids of those groupings, not +of the individual rows. + +Each strategy declares, as `aclChecks`, how thoroughly it verifies observer access to them, and the +gate refuses a `collections` scope a strategy cannot honour: + +| Strategy | `aclChecks` | A `collections` scope | +| --- | --- | --- | +| `trackedCollectionObservers` | `per-read` | checked for every observer, on every read | +| `privateObservers` | `no-observers` | accepted; nobody is admitted to exclude | +| `aclObservers` | `unsupported` | **refused** | +| `openObservers` | `unsupported` | **refused** | + +A resource whose children carry their own ACLs needs `trackedCollectionObservers`. Under the other two, +collection ids would name a check nothing performs, so declare those reads `{ kind: "baseline" }` — and if +the provider can revoke child access independently, that is the wrong strategy, not the wrong +scope. A custom strategy declares its own `aclChecks`, and only the `per-read` arm may carry +`prepare`, so claiming a check it does not implement will not compile. + +### A cursor authorizes everything it hands out + +A provider-backed cursor returns provider data from `next()`, so it carries the same obligation. The +session cannot discharge it up front: the pages do not exist yet, and one `next()` may be served +from the buffer with no provider fetch at all. Pass `authorizePage`, which the cursor calls with the +exact page it is about to return: + +```ts +// A cursor is walked after this call returns, so it takes its own gate with `lease()` and +// releases it from `dispose`. Built on the session's gate instead, the first `next()` after the +// session releases its stub fails on the authorization rather than the data. +const walk = this.#gate.lease(); +return new TokenCursor({ + pageSize: 50, + dispose: () => walk[Symbol.dispose](), + fetchPage: (token, perPage) => this.#api.listProjects({ cursor: token, limit: perPage }), + authorizePage: (projects, { terminal }) => projects.length === 0 + ? walk.authorize( + { + title: "Projects", + description: terminal + ? "Listed the projects; there were none." + : "Scanned a window of projects; none were visible.", + }, + { kind: "baseline" }) + : walk.authorize( + { title: "Projects", description: `Read ${projects.length} projects.` }, + { kind: "collections", ids: projects.map(project => project.id) }), +}); +``` + +The lease and the session gate share the binding's strategy, so exclusions stay one decision; only +the stub is duplicated, and either side can be released without disturbing the other. `lease()` +needs a real `RpcStub` — the overseer hands one over, but a gate built from a service binding +cannot duplicate it, since `dup` is reserved over RPC. + +Every page is authorized, including an empty one from a spent fetch window. So is the end of a walk +that disclosed nothing: `searchUsers(email) → no matches` answers a question about provider data, +and letting that reach the gadget unaudited turns the cursor into an existence oracle. A walk that +already returned a page does not re-authorize its `null`, and exhaustion is authorized at most once. + +Branch on `projects.length`, not on `terminal`. Both an exhausted walk and a spent mid-walk window +arrive with no items, and `{ kind: "collections", ids: [] }` is refused — naming no collection is exactly the +shape the gate rejects. `terminal` distinguishes the two only for the description: whether the walk +is over, or the caller should ask again. The `collections` branch above also assumes +`trackedCollectionObservers`; under a strategy whose `aclChecks` is `"unsupported"` every branch is +`baseline`, per the table above. + +A refusal holds the outgoing page, so the retry re-offers exactly it with no further provider +fetch: a page the provider capped short cannot grow between the refusal and the retry, which would +hand the approver something larger than what they refused. An empty page from a spent window is the +exception — nothing was disclosed, so the retry opens a fresh window rather than pinning the walk +on a failure that may have been transient. A refused zero-result answer is likewise re-offered. +`ArrayCursor` takes no callback: the session that assembled its items authorized them as one read. + +That hold is also why a walk pinned to a connection must re-check its authority in +`authorizePage`, not only in `fetchPage`. The retry never re-enters the fetch, so a reconnect +landing between the refusal and the retry would otherwise disclose the previous connection's rows +under the new one. Compare against a read taken now — a value captured earlier names the +connection the walk opened under, not the current one: + +```ts +const opened = await this.#creds.read(); +return new TokenCursor({ + authorizePage: async projects => { + if ((await this.#creds.read()).generation !== opened.generation) { + throw new Error("This walk was started under a connection that has since been replaced."); + } + await walk.authorize(/* … */); + }, + // … +}); +``` + +### Refusal and failure have different outcomes + +`ObservationGate.authorize()` reclaims prepared state only when an error carries +`OBSERVATION_REFUSED_CODE`. That mark proves the overseer refused the observation before recording +anything. + +Every other failure has an unknown outcome. The gate releases in-memory bookkeeping but retains +durable fences because a lost reply may have left an observation record. A tracked-collection marker is +reclaimed only after every read that disclosed the collection was refused. One unknown result retains it. + +The Workshop overseer does not yet add this code: both pre-recording refusal paths — owner-only +observations in shared workspaces, and collaborator exclusions — still throw plain errors. Until a +kernel change marks them, every failure takes the fail-closed unknown-outcome path above, and +`discard()` never runs. For a `withholdFromObservers` read that is not merely a retained marker: +`abandon` latches the binding unshareable for good, so a refused owner-only read costs the +workspace its sharing until the kernel distinguishes the two. + +## Bounds + +Every cap must be a positive safe integer. Constructors reject zero, fractional, and unsafe values +instead of allowing a bound to disable itself. + +The kit supplies defaults where they apply across consumers: + +| Option | Default | +| --- | ---: | +| `maxPending` | 50 | +| `maxTrackedCollections` | 1000 | +| `maxObservers` | 10 | +| `remotePageSize` | 100 | + +The kit requires values where no general default is safe: + +- Every cursor's `pageSize`. +- `ActionFileStore`'s `maxFileBytes` and `maxTotalBytes`. + +Size limits from the provider and the disclosure shape. `maxTrackedCollections` is a cumulative budget: it +bounds the distinct collections this binding has *ever* disclosed, including markers a fail-closed read +left behind, so size it from the whole resource rather than one page — a per-page value starts +refusing valid reads once later pages reveal new collections. `maxObservers` must account for the Workers +subrequest ceiling because every observer costs a verifier call on each read. `remotePageSize` +cannot exceed the provider's page cap. + +## Other module boundaries + +- Use `withAuthRetry` only for token flows without `CredentialSource`. Otherwise, + `CredentialSource.run()` owns refresh, replay, and expiry reporting. +- Use `isNoAccessError` or `probeAccess` for observer ACL checks. Do not use `isNoAccessError` as + `CredentialSource.isAuthError`; it accepts 403 and 404. +- Use `readTextCapped` for every textual, JSON, or error body — a provider can return more bytes + than the Worker can hold. It decodes UTF-8 and buffers, so binary downloads and streaming + protocols (SSE, Git) need their own byte-preserving limit instead. +- Use `normalizeVendorEndpoint` for user-supplied provider base URLs. It validates that one URL and + is not a fetch policy: fetch with `redirect: "manual"`, or re-validate each `Location` and drop + origin-scoped headers when the origin changes, or a redirect carries `Authorization` off the + allowlisted host. +- Use `PreviewOAuth` when previews must share one stable callback registered with the OAuth + provider. +- Every callback the kit invokes must throw display-safe errors. `discardMint`, the rejection heal, + and the expiry notification all log what they catch, so a thrown token, header, or response body + lands in the deployment's logs. diff --git a/packages/gatekeeper-kit/__tests__/action-files.test.ts b/packages/gatekeeper-kit/__tests__/action-files.test.ts index 58650e594a..58b0f644a4 100644 --- a/packages/gatekeeper-kit/__tests__/action-files.test.ts +++ b/packages/gatekeeper-kit/__tests__/action-files.test.ts @@ -213,8 +213,8 @@ describe("ActionFileStore", () => { }); it.each([ - [{ maxFileBytes: 0 }, /maxFileBytes must be a positive integer/], - [{ maxTotalBytes: 0 }, /maxTotalBytes must be a positive integer/], + [{ maxFileBytes: 0 }, /maxFileBytes must be a positive safe integer/], + [{ maxTotalBytes: 0 }, /maxTotalBytes must be a positive safe integer/], ] as const)("rejects invalid limits %#", (overrides, error) => { expect(() => actionFiles(overrides)).toThrow(error); }); diff --git a/packages/gatekeeper-kit/__tests__/actions.test.ts b/packages/gatekeeper-kit/__tests__/actions.test.ts index 75da80c5f0..ac84a52ca2 100644 --- a/packages/gatekeeper-kit/__tests__/actions.test.ts +++ b/packages/gatekeeper-kit/__tests__/actions.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import type { ApprovalQueue } from "@gadgets/workshop-shared/gatekeeper"; +import type { ApprovalQueue, GitCache } from "@gadgets/workshop-shared/gatekeeper"; import type { RpcStub } from "cloudflare:workers"; import { ActionApplyError, + type FencePolicy, + ActionOutcomeUnknownError, ActionJournal, APPLY_OUTCOME_UNKNOWN_MESSAGE, defineActions, stageAction, + type ActionContext, type ActionDefinition, type ActionJournalKv, type ActionPresentation, @@ -14,7 +17,7 @@ import { type ResolveOutcome, type TaggedAction, } from "../src/actions"; -import { ObservationGate, openObservers } from "../src/observers"; +import type { CredentialRead } from "../src/credentials"; import { fakeKv } from "./fake-kv"; function makeKv() { @@ -37,7 +40,7 @@ function fakeQueue(submitAction = submitSpy()): ActionSubmitter { describe("ActionJournal", () => { it("allocates sequential ids and lists only submitted actions, ordered numerically", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const ids = Array.from({ length: 10 }, (_, index) => journal.allocate({ sql: `q${index}` })); expect(ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); @@ -54,7 +57,7 @@ describe("ActionJournal", () => { it("refuses to stage over a live id, which a port of a last-issued counter would reissue", () => { const kv = makeKv(); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); const id = journal.allocate({ sql: "live" }); // A last-issued convention stores the id it just returned, not the next unused one. kv.put("pending:nextActionId", id); @@ -66,7 +69,7 @@ describe("ActionJournal", () => { it("refuses to stage over a legacy row its upgrade hook cannot convert", () => { const kv = makeKv(); kv.put("pending:action:1", "opaque legacy row"); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); expect(() => journal.allocate({ sql: "clobber" })).toThrow(/next unused id/); expect(kv.get("pending:action:1")).toBe("opaque legacy row"); @@ -74,7 +77,7 @@ describe("ActionJournal", () => { it("refuses to stage over a retired id, whose applied memory would swallow the new apply", () => { const kv = makeKv(); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); const id = journal.allocate({ sql: "applied" }); journal.markSubmitted(id); journal.retire(id); @@ -84,7 +87,7 @@ describe("ActionJournal", () => { }); it("remembers retired ids within the prunable allowance, and forgets the oldest past it", () => { - const journal = new ActionJournal(makeKv(), { maxPending: 1 }); + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 1 }); const ids = ["a", "b", "c"].map(sql => { const id = journal.allocate({ sql }); journal.markSubmitted(id); @@ -98,7 +101,7 @@ describe("ActionJournal", () => { it("resolves a staged record, and keeps its id counter out of the record keyspace", () => { const kv = makeKv(); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); const id = journal.allocate({ sql: "staged" }); expect(journal.get(id)).toEqual({ state: "staged", action: { sql: "staged" } }); @@ -111,7 +114,7 @@ describe("ActionJournal", () => { it("moves a retained record out of the pending scan while keeping it findable", () => { const kv = makeKv(); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); const retained = journal.allocate({ sql: "applied" }); const pending = journal.allocate({ sql: "waiting" }); journal.markSubmitted(retained); @@ -132,6 +135,73 @@ describe("ActionJournal", () => { expect(journal.get(retained)).toBeUndefined(); }); + it("enumerates retained actions in storage-bounded resumable pages", () => { + const kv = makeKv(); + const scan = vi.spyOn(kv, "list"); + const journal = new ActionJournal(kv, { namespace: "pending" }); + const ids = ["one", "two", "three"].map(sql => { + const id = journal.allocate({ sql }); + journal.retain(id); + return id; + }); + // A malformed/non-applied row still consumes storage's limit. The cursor must advance past it + // rather than claiming this sparse page exhausted the retained tier. + kv.put(`retained:pending:action:${ids[1]}`, + { v: 2, state: "pending", action: { sql: "not retained" } }); + scan.mockClear(); + + const first = journal.listRetained({ limit: 2 }); + expect(first.entries).toEqual([{ id: ids[0], action: { sql: "one" } }]); + expect(first.nextCursor).toBe(`retained:pending:action:${ids[1]}`); + expect(scan).toHaveBeenNthCalledWith(1, + { prefix: "retained:pending:action:", limit: 2 }); + + expect(journal.listRetained({ limit: 2, cursor: first.nextCursor })).toEqual({ + entries: [{ id: ids[2], action: { sql: "three" } }], + }); + expect(scan).toHaveBeenNthCalledWith(2, { + prefix: "retained:pending:action:", + startAfter: first.nextCursor, + limit: 2, + }); + }); + + it("finishes the delete of a retained row whose retire only got to the tombstone", () => { + // `retire` writes the tombstone first, so a throwing delete leaves the row behind. Returning + // it would hand a consumer a finished action twice, so the scan retires it instead. + const kv = makeKv(); + const journal = new ActionJournal(kv, { namespace: "pending" }); + const id = journal.allocate({ sql: "one" }); + journal.retain(id); + const failing = new ActionJournal({ + ...kv, + delete: key => { if (key.startsWith("retained:")) throw new Error("storage unavailable"); }, + }, { namespace: "pending" }); + + expect(() => failing.retire(id)).toThrow("storage unavailable"); + + expect(journal.wasApplied(id)).toBe(true); + expect(journal.listRetained({ limit: 10 }).entries).toEqual([]); + // The scan finished the interrupted delete, so omitting the row no longer depends on its id + // surviving the bounded tombstone memory. + expect(journal.isRetained(id)).toBe(false); + }); + + it("sends a port's own versioned rows through upgradeRecord rather than reading them as kit records", () => { + // `legacyKeys` points the journal at rows the port already wrote, and `v: 1` is what any prior + // scheme most likely stamped them with. Reading one as a kit record yields a stateless row + // that holds a pending slot no approval can clear. + const kv = makeKv(); + kv.put("legacy:rec:7", { v: 1, sql: "the port's own schema" }); + const journal = new ActionJournal(kv, { + legacyKeys: { nextIdKey: "legacy:next", recordPrefix: "legacy:rec:" }, + upgradeRecord: raw => ({ sql: (raw as { sql: string }).sql }), + }); + + expect(journal.get(7)).toEqual({ state: "pending", action: { sql: "the port's own schema" } }); + expect(journal.listPending()).toEqual([{ id: 7, action: { sql: "the port's own schema" } }]); + }); + it("keeps the applied record when the pending one cannot be deleted", () => { // A throw does not roll back the implicit transaction, so write order decides what an // interrupted retain leaves. Deleting first would lose the record for an applied action. @@ -140,7 +210,7 @@ describe("ActionJournal", () => { ...kv, delete: key => { throw new Error(`storage unavailable: ${key}`); }, }; - const journal = new ActionJournal(failing); + const journal = new ActionJournal(failing, { namespace: "pending" }); const id = journal.allocate({ sql: "applied" }); journal.markSubmitted(id); @@ -155,13 +225,13 @@ describe("ActionJournal", () => { expect(kv.get(`retained:pending:action:${id}`)).toBeDefined(); // Including the cap: the record is applied, so it must not hold a pending slot for good. - const capped = new ActionJournal(kv, { maxPending: 1 }); + const capped = new ActionJournal(kv, { namespace: "pending", maxPending: 1 }); expect(capped.allocate({ sql: "next" })).toBe(id + 1); }); it("ignores a scanned key whose suffix is not the id it would coerce to", () => { const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 2 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 2 }); const id = journal.allocate({ sql: "one" }); journal.markSubmitted(id); @@ -169,7 +239,7 @@ describe("ActionJournal", () => { // alias the live record: the capacity scan would count it, and `remove(1)` would delete the // wrong key. `1e3` and a bare prefix are the same class of coercion. for (const suffix of ["01", "1e3", "", " 2", "0"]) { - kv.put(`pending:action:${suffix}`, { v: 1, state: "pending", action: { sql: suffix } }); + kv.put(`pending:action:${suffix}`, { v: 2, state: "pending", action: { sql: suffix } }); } expect(journal.listPending()).toEqual([{ id, action: { sql: "one" } }]); @@ -185,12 +255,12 @@ describe("ActionJournal", () => { ...kv, delete: key => { throw new Error(`storage unavailable: ${key}`); }, }; - const id = new ActionJournal(failing).allocate({ sql: "applied" }); - expect(() => new ActionJournal(failing).retain(id, { sql: "applied", rows: 3 })) + const id = new ActionJournal(failing, { namespace: "pending" }).allocate({ sql: "applied" }); + expect(() => new ActionJournal(failing, { namespace: "pending" }).retain(id, { sql: "applied", rows: 3 })) .toThrow("storage unavailable"); // Enough staged records to force pruning; the retained one must not be among the casualties. - const journal = new ActionJournal(kv, { maxPending: 1 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 1 }); journal.allocate({ sql: "second" }); journal.allocate({ sql: "third" }); @@ -204,6 +274,7 @@ describe("ActionJournal", () => { // values, so only the absent version marker can route it to the upgrader. kv.put("pending:action:7", { action: { statement: "legacy" }, state: "pending" }); const journal = new ActionJournal(kv, { + namespace: "pending", // The shape this gatekeeper wrote before it had a journal; unvalidatable by construction. upgradeRecord: raw => ({ sql: (raw as { action: { statement: string } }).action.statement }), }); @@ -213,7 +284,7 @@ describe("ActionJournal", () => { }); it("leaves a record alone once it has been resolved mid-submission", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const id = journal.allocate({ sql: "one" }); // An auto-approval can apply and retain the record while submitAction is still in flight. @@ -226,27 +297,66 @@ describe("ActionJournal", () => { }); it("refuses overlapping keyspaces a port could pass by hand", () => { - expect(() => new ActionJournal(makeKv(), { recordPrefix: "" })) - .toThrow(/must not be empty/); - expect(() => new ActionJournal(makeKv(), { nextIdKey: "action:1", recordPrefix: "action:" })) + const legacy = (nextIdKey: string, recordPrefix: string) => + () => new ActionJournal(makeKv(), { legacyKeys: { nextIdKey, recordPrefix } }); + expect(legacy("pending:nextActionId", "")).toThrow(/must not be empty/); + expect(legacy("action:1", "action:")).toThrow(/overlaps a record prefix/); + expect(legacy("retained:pending:action:1", "pending:action:")) .toThrow(/overlaps a record prefix/); - expect(() => new ActionJournal(makeKv(), { nextIdKey: "retained:pending:action:1" })) - .toThrow(/overlaps a record prefix/); - expect(() => new ActionJournal(makeKv(), { recordPrefix: "retained:" })) - .toThrow(/its own retained tier/); + expect(legacy("pending:nextActionId", "retained:")).toThrow(/its own retained tier/); + }); + + it("refuses a namespace that could forge a key boundary", () => { + // A derived key is `:action:`; a separator in the namespace itself would let + // one journal's records fall inside another's prefix scan. + for (const namespace of ["", "has space", "has:colon", "retained:pending"]) { + expect(() => new ActionJournal(makeKv(), { namespace })) + .toThrow(/must match/); + } + }); + + it("refuses a namespace that lands its records inside observer storage", () => { + // `observer:action:1` comes back from the observer scan as a stored verifier, and + // `observer-withhold-fence:action:1` reads as an owner-only read nothing will ever settle. + for (const namespace of ["observer", "observer-attempt", "observer-nonce", + "observer-withhold-fence", "observer-withhold-latch"]) { + expect(() => new ActionJournal(makeKv(), { namespace })) + .toThrow(/overlap the reserved observer prefix/); + } + expect(() => new ActionJournal(makeKv(), { namespace: "observations" })).not.toThrow(); + }); + + it("keeps two namespaces over one storage from sharing ids, records, or capacity", () => { + const kv = makeKv(); + const calendar = new ActionJournal(kv, { namespace: "calendar", maxPending: 1 }); + const mail = new ActionJournal(kv, { namespace: "mail", maxPending: 1 }); + + const first = calendar.allocate({ sql: "invite" }); + // Its own id sequence, its own record, and its own capacity: the shared cap would have thrown. + expect(mail.allocate({ sql: "draft" })).toBe(first); + expect(mail.get(first)).toEqual({ state: "staged", action: { sql: "draft" } }); + expect(calendar.get(first)).toEqual({ state: "staged", action: { sql: "invite" } }); }); it("refuses a pending cap that would disable itself", () => { // `NaN` fails every comparison the cap appears in, so it silently removes the bound; zero and // below refuse the first allocation instead of the last. for (const maxPending of [Number.NaN, Infinity, 0, -1, 1.5]) { - expect(() => new ActionJournal(makeKv(), { maxPending })) - .toThrow(/maxPending must be a positive integer/); + expect(() => new ActionJournal(makeKv(), { namespace: "pending", maxPending })) + .toThrow(/maxPending must be a positive safe integer/); + } + }); + + it("refuses a retained-page limit that would disable its storage bound", () => { + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); + for (const limit of [Number.NaN, Infinity, 0, -1, 1.5]) { + expect(() => journal.listRetained({ limit })) + .toThrow(/limit must be a positive safe integer/); } }); it("projects a claimed record but stops projecting a failed one", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const [pending, claimed, failed] = [1, 2, 3].map(n => journal.allocate({ sql: `q${n}` })); for (const id of [pending, claimed, failed]) journal.markSubmitted(id); @@ -264,7 +374,7 @@ describe("ActionJournal", () => { }); it("excludes a claimed record from the decisions a resolution may still retire", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const staged = journal.allocate({ sql: "staged" }); const pending = journal.allocate({ sql: "pending" }); const claimed = journal.allocate({ sql: "claimed" }); @@ -279,7 +389,7 @@ describe("ActionJournal", () => { }); it("never moves a record out of a state it has already settled in", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const applied = journal.allocate({ sql: "applied" }); const failed = journal.allocate({ sql: "failed" }); journal.retain(applied); @@ -299,7 +409,7 @@ describe("ActionJournal", () => { it("refuses to allocate past the pending cap, and lets a failure be cleared", () => { const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 2 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 2 }); const first = journal.allocate({ sql: "one" }); const second = journal.allocate({ sql: "two" }); journal.markSubmitted(first); @@ -317,7 +427,7 @@ describe("ActionJournal", () => { }); it("caps unresolved actions at 50 by default", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); for (let attempt = 1; attempt <= 50; attempt += 1) { journal.markSubmitted(journal.allocate({ sql: `q${attempt}` })); } @@ -329,7 +439,7 @@ describe("ActionJournal", () => { // A staged record never reached the overseer, so no approval queue entry can clear it; counted, // a crash between `allocate` and `submitAction` would wedge this resource for good. const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 2 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 2 }); for (const sql of ["one", "two", "three", "four", "five"]) journal.allocate({ sql }); expect(journal.allocate({ sql: "six" })).toBe(6); @@ -342,7 +452,7 @@ describe("ActionJournal", () => { it("never lets terminal failures block a new action", () => { // Why they get their own bound: counted against the cap, a run of provider failures would stop // the agent staging anything until the user cleared them by hand. - const journal = new ActionJournal(makeKv(), { maxPending: 2 }); + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 2 }); for (let attempt = 1; attempt <= 50; attempt += 1) { journal.markFailed(journal.allocate({ sql: `q${attempt}` }), "terminal"); } @@ -354,7 +464,7 @@ describe("ActionJournal", () => { // Failures are excluded from the cap but live under the scanned prefix, so unbounded they would // make every later allocation and every simulation scan progressively more expensive. const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 2 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 2 }); for (let attempt = 1; attempt <= 20; attempt += 1) { journal.markFailed(journal.allocate({ sql: `q${attempt}` }), "terminal"); } @@ -371,7 +481,7 @@ describe("ActionJournal", () => { // An unclamped excess drops records while the bound is not even reached, since a negative // `slice` end counts back from the array's own length. const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 4 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 4 }); for (let attempt = 1; attempt <= 7; attempt += 1) { journal.markFailed(journal.allocate({ sql: `q${attempt}` }), "terminal"); } @@ -383,7 +493,7 @@ describe("ActionJournal", () => { it("drops a stranded staged record before a failure that explains itself", () => { const kv = makeKv(); - const journal = new ActionJournal(kv, { maxPending: 1 }); + const journal = new ActionJournal(kv, { namespace: "pending", maxPending: 1 }); journal.markFailed(journal.allocate({ sql: "explained" }), "terminal"); journal.allocate({ sql: "never submitted" }); journal.allocate({ sql: "never submitted either" }); @@ -394,18 +504,55 @@ describe("ActionJournal", () => { expect(journal.get(2)).toBeUndefined(); }); + it("blocks on an undispatched failure rather than pruning the cleanup it owes", () => { + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 1 }); + const owed = journal.allocate({ sql: "owed" }); + journal.markFailed(owed, "no handler ran", { undispatched: true }); + + // Discarding the record would strand what staging set up, since only a rejection releases it. + // Holding a slot is recoverable: the user rejects it, and that runs the cleanup. + expect(() => journal.allocate({ sql: "next" })).toThrow(/Too many pending actions/); + journal.remove(owed); + expect(journal.allocate({ sql: "next" })).toBeGreaterThan(owed); + }); + + it("blocks on an unknown outcome rather than pruning the provider warning", () => { + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 1 }); + const uncertain = journal.allocate({ sql: "maybe ran" }); + journal.markFailed(uncertain, "the request timed out", { outcome: "unknown" }); + + // An ordinary terminal failure is prunable; this one is the only record saying the provider + // may already have changed, so it holds a slot until the user clears it. + expect(() => journal.allocate({ sql: "next" })).toThrow(/Too many pending actions/); + journal.remove(uncertain); + expect(journal.allocate({ sql: "next" })).toBeGreaterThan(uncertain); + }); + + it("never prunes an unknown outcome, however many prunable failures pile up beside it", () => { + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 2 }); + const uncertain = journal.allocate({ sql: "maybe ran" }); + journal.markFailed(uncertain, "the request timed out", { outcome: "unknown" }); + // Well past the prunable bound of `2 x maxPending`, which evicts oldest-first. + for (let index = 0; index < 12; index++) { + const id = journal.allocate({ sql: `failure ${index}` }); + journal.markFailed(id, "provider refused"); + } + + expect(journal.get(uncertain)).toMatchObject({ state: "failed", outcome: "unknown" }); + }); + it("answers for a failed record whose reason storage lost", () => { const kv = makeKv(); - const journal = new ActionJournal(kv); + const journal = new ActionJournal(kv, { namespace: "pending" }); const id = journal.allocate({ sql: "one" }); - kv.put(`pending:action:${id}`, { v: 1, state: "failed", action: { sql: "one" } }); + kv.put(`pending:action:${id}`, { v: 2, state: "failed", action: { sql: "one" } }); // The type promises a failed record carries its reason; the storage boundary keeps that true. expect(journal.get(id)?.error).toBe("This action failed, and the reason was not recorded."); }); it("refuses to retain a terminal failure, which would drop the reason it answers with", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const id = journal.allocate({ sql: "one" }); journal.markFailed(id, "the provider refused"); @@ -418,7 +565,7 @@ describe("ActionJournal", () => { }); it("bounds a stored failure reason, so the rewrite cannot outgrow the value limit", () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const id = journal.allocate({ sql: "one" }); journal.markFailed(id, "x".repeat(50_000)); @@ -431,7 +578,7 @@ describe("ActionJournal", () => { describe("stageAction", () => { it("submits the allocated id, then marks the action pending", async () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const submitAction = submitSpy(); const id = await stageAction(journal, fakeQueue(submitAction), { sql: "one" }, presentation); @@ -441,7 +588,7 @@ describe("stageAction", () => { }); it("rolls the record back when submission fails", async () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const submitAction = vi.fn(async () => { throw new Error("queue unavailable"); }); @@ -454,7 +601,7 @@ describe("stageAction", () => { it("reports success when only the reply to an auto-approved submission was lost", async () => { // The overseer applied and retained the action inside submitAction, then the RPC rejected. - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const submitAction = vi.fn(async submitted => { journal.retain(submitted); throw new Error("session torn down"); @@ -465,7 +612,7 @@ describe("stageAction", () => { }); it("reports success when a non-retaining auto-approval consumed the record mid-submission", async () => { - const journal = new ActionJournal(makeKv()); + const journal = new ActionJournal(makeKv(), { namespace: "pending" }); const submitAction = vi.fn(async submitted => { journal.remove(submitted); throw new Error("session torn down"); @@ -480,7 +627,7 @@ describe("stageAction", () => { it("serializes direct concurrent callers, so the prune cannot take a record mid-flight", async () => { // Unserialized, these stages would all hold staged records open at once and the last one's // capacity prune would delete the oldest -- an approval with no journal record behind it. - const journal = new ActionJournal(makeKv(), { maxPending: 1 }); + const journal = new ActionJournal(makeKv(), { namespace: "pending", maxPending: 1 }); const submitAction = submitSpy(); const queue = fakeQueue(submitAction); @@ -504,12 +651,13 @@ describe("defineActions", () => { type Host = { ran: string[] }; function bind(overrides: { - apply?: (payload: Sql, host: Host, ctx: { id: number }) => Promise; - reject?: (payload: Sql, host: Host, ctx: { id: number }) => Promise; + apply?: (payload: Sql, host: Host, ctx: ActionContext) => Promise; + reject?: (payload: Sql, host: Host, ctx: ActionContext) => Promise; describe?: (payload: Sql, host: Host) => ActionPresentation; retainApplied?: boolean; afterResolve?: (host: Host, outcome: ResolveOutcome) => void | Promise; claimBeforeApply?: boolean; + fence?: FencePolicy; maxPending?: number; /** Share one journal between two binds, which is how a dead activation is simulated. */ journal?: ActionJournal>; @@ -517,7 +665,7 @@ describe("defineActions", () => { const host: Host = { ran: [] }; const outcomes: ResolveOutcome[] = []; const journal = overrides.journal - ?? new ActionJournal>(makeKv(), { maxPending: overrides.maxPending }); + ?? new ActionJournal>(makeKv(), { namespace: "pending", maxPending: overrides.maxPending }); const set = defineActions({ execute: { kind: { tag: "sql", label: "Run SQL" }, @@ -537,6 +685,8 @@ describe("defineActions", () => { apply: async (payload, target) => void target.ran.push(payload.page), }, }, { + // Most tests here stage unfenced actions; the fence suite opts its own set into pinning. + fence: overrides.fence ?? "none", retainApplied: overrides.retainApplied, afterResolve: overrides.afterResolve ?? ((_host, outcome) => void outcomes.push(outcome)), @@ -560,21 +710,6 @@ describe("defineActions", () => { ]); }); - it("stages through the gate's borrowed action surface", async () => { - // The one-dup session shape: `gate.actions` must satisfy `ActionSubmitter` without a cast. - const { actions, journal } = bind(); - const submitAction = submitSpy(); - const gate = new ObservationGate( - { submitAction } as unknown as RpcStub, openObservers()); - - const id = await actions.submit(gate.actions, "execute", { sql: "one" }); - - expect(submitAction).toHaveBeenCalledWith(id, expect.objectContaining(presentation)); - expect(journal.listPending()).toEqual([ - { id, action: { kind: "execute", payload: { sql: "one" } } }, - ]); - }); - it("journals the payload as submitted, not as the caller mutated it afterwards", async () => { // What the approver reads and what apply receives must be the same payload, so submit snapshots // it before its first await -- the caller's reference is live until the KV put otherwise. @@ -639,9 +774,8 @@ describe("defineActions", () => { }); it("keeps a describe hook from overriding the delivery its definition declares", async () => { - // `ActionPresentation` is a `Pick` of `ActionDescription`, so a port reusing a fully typed - // description here type-checks -- and used to carry its `awaitDecision` past `execute`'s - // declared `continue-with-simulation`. + // A port reusing a fully typed `ActionDescription` here has to cast, and used to carry its + // `awaitDecision` past `execute`'s declared `continue-with-simulation`. const { actions } = bind({ describe: () => ({ ...presentation, awaitDecision: true, autoApprovable: false } as never), }); @@ -656,6 +790,54 @@ describe("defineActions", () => { }); }); + it("forwards the commits a description declares, which scope the action's git cache", async () => { + const { actions } = bind({ + describe: () => ({ ...presentation, pushedCommits: ["abc"] }), + }); + const submitAction = submitSpy(); + + const id = await actions.submit(fakeQueue(submitAction), "execute", { sql: "one" }); + + expect(submitAction).toHaveBeenCalledWith(id, { + ...presentation, + pushedCommits: ["abc"], + actionKind: { tag: "sql", label: "Run SQL" }, + autoApprovable: true, + }); + }); + + it("sends the commits described at staging time, not ones added while the lane was busy", async () => { + // Staging serializes per journal, so a description built now reaches the queue only after the + // submission ahead of it settles. A describe hook returning an array it still owns must not be + // able to grow the push the approver sees inside that window. + const commits = ["abc"]; + const { actions } = bind({ describe: () => ({ ...presentation, pushedCommits: commits }) }); + const blocking = Promise.withResolvers(); + const submitAction = vi.fn(async () => { await blocking.promise; }); + + const queue = fakeQueue(submitAction); + const first = actions.submit(queue, "execute", { sql: "one" }); + const second = actions.submit(queue, "execute", { sql: "two" }); + await vi.waitFor(() => expect(submitAction).toHaveBeenCalled()); + commits.push("def"); + blocking.resolve(); + await Promise.all([first, second]); + + for (const [, description] of submitAction.mock.calls) { + expect(description.pushedCommits).toEqual(["abc"]); + } + }); + + it("puts no pushedCommits key on the wire when the description declares none", async () => { + // Absent, not `undefined`: the overseer reads presence as "this action pushes". + const { actions } = bind(); + const submitAction = submitSpy(); + + await actions.submit(fakeQueue(submitAction), "execute", { sql: "one" }); + + expect(Object.hasOwn(submitAction.mock.calls[0]![1], "pushedCommits")).toBe(false); + }); + it("declares the delivery hint the kind asked for, and no key when it did not", async () => { const { actions } = bind(); const submitAction = submitSpy(); @@ -688,7 +870,25 @@ describe("defineActions", () => { describe: () => presentation, apply: async () => {}, }, - })).toThrow(/autoApprovable without a kind/); + }, { fence: "none" })).toThrow(/autoApprovable without a kind/); + }); + + it("refuses a set that declares dependsOn with no way to resolve the reference", () => { + // Without a resolver apply hands the provider the provisional string, and the strand cascade + // reads every reference as dead. Both are silent, so the declaration is refused instead. + const definitions = { + execute: { + delivery: "continue-with-simulation" as const, + describe: () => presentation, + dependsOn: (payload: Sql) => [payload.sql], + apply: async () => {}, + }, + }; + + expect(() => defineActions(definitions, { fence: "none" })) + .toThrow(/declares dependsOn, so the set needs isResolvedReference/); + expect(() => defineActions( + definitions, { fence: "none", isResolvedReference: () => true })).not.toThrow(); }); it("refuses a tag whose siblings disagree about the label shown for it", () => { @@ -705,7 +905,7 @@ describe("defineActions", () => { describe: () => presentation, apply: async () => {}, }, - })).toThrow(/tag "sql" is declared with two labels, "Run SQL" and "Publish"/); + }, { fence: "none" })).toThrow(/tag "sql" is declared with two labels, "Run SQL" and "Publish"/); }); it("auto-approves only the kinds that declared it, not their tag siblings", async () => { @@ -1054,21 +1254,56 @@ describe("defineActions", () => { const journal = new ActionJournal>({ ...kv, delete: key => { throw new Error(`storage unavailable: ${key}`); }, - }); + }, { namespace: "pending" }); const { actions, host, outcomes } = bind({ journal, claimBeforeApply: true }); const id = journal.allocate({ kind: "execute", payload: { sql: "one" } }); journal.markSubmitted(id); await expect(actions.apply(id)).rejects.toThrow("storage unavailable"); expect(host.ran).toEqual(["one"]); - expect(journal.get(id)?.state).toBe("claimed"); expect(outcomes).toEqual([]); - // The claim outlived the attempt that wrote it, so the next one reports the unknown outcome - // rather than running the handler over an effect that already happened. - await expect(actions.apply(id)).rejects.toThrow(APPLY_OUTCOME_UNKNOWN_MESSAGE); + // The retire tombstoned the id before attempting the delete, so the effect is known applied: + // the stale record stops projecting and stops holding a pending slot. + expect(journal.wasApplied(id)).toBe(true); + expect(journal.listPending()).toEqual([]); + + // A retry answers success rather than reporting a landed action as failed: it retries the + // cleanup, and a delete that fails again is logged, not raised. + const logged = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(actions.apply(id)).resolves.toBeUndefined(); + expect(logged).toHaveBeenCalledOnce(); + } finally { + logged.mockRestore(); + } expect(host.ran).toEqual(["one"]); - expect(outcomes).toEqual(["failed"]); + expect(outcomes).toEqual([]); + + // And the executed action can never be reported rejected, whatever the record still says. + await expect(actions.reject(id)).rejects.toThrow(/no longer pending/); + }); + + it("heals an interrupted retire on the next apply, leaving no record behind", async () => { + const kv = makeKv(); + let failDelete = true; + const journal = new ActionJournal>({ + ...kv, + delete: key => { + if (failDelete) throw new Error(`storage unavailable: ${key}`); + kv.delete(key); + }, + }, { namespace: "pending" }); + const { actions, host } = bind({ journal }); + const id = journal.allocate({ kind: "execute", payload: { sql: "one" } }); + journal.markSubmitted(id); + + await expect(actions.apply(id)).rejects.toThrow("storage unavailable"); + + failDelete = false; + await expect(actions.apply(id)).resolves.toBeUndefined(); + expect(host.ran).toEqual(["one"]); + expect(kv.get(`pending:action:${id}`)).toBeUndefined(); }); it("refuses to submit past the pending cap, leaving nothing staged", async () => { @@ -1101,7 +1336,7 @@ describe("defineActions", () => { /** A record deploy A staged under a kind deploy B has since renamed or removed. */ function staleKind(kind = "archive") { - const journal = new ActionJournal>(makeKv()); + const journal = new ActionJournal>(makeKv(), { namespace: "pending" }); const id = journal.allocate({ kind, payload: { sql: "one" } } as never); journal.markSubmitted(id); return { id, ...bind({ journal }) }; @@ -1133,14 +1368,14 @@ describe("defineActions", () => { // `submit` reaches its definition by property access, which coerces `7` to `"7"`; a Map lookup // does not. Uncoerced, the set reports a kind unsupported that it had just accepted. const host: Host = { ran: [] }; - const journal = new ActionJournal>(makeKv()); + const journal = new ActionJournal>(makeKv(), { namespace: "pending" }); const actions = defineActions({ 7: { delivery: "await-decision", describe: () => presentation, apply: async (payload, target) => void target.ran.push(payload.sql), }, - }).bind(journal, host); + }, { fence: "none" }).bind(journal, host); const id = journal.allocate({ kind: 7, payload: { sql: "one" } }); journal.markSubmitted(id); @@ -1151,14 +1386,14 @@ describe("defineActions", () => { it("rebinds a journal to the first bound set, so a per-call bind still shares one queue", () => { const host: Host = { ran: [] }; - const journal = new ActionJournal>(makeKv()); + const journal = new ActionJournal>(makeKv(), { namespace: "pending" }); const set = defineActions({ execute: { delivery: "await-decision", describe: () => presentation, apply: async (payload, target) => void target.ran.push(payload.sql), }, - }); + }, { fence: "none" }); const actions = set.bind(journal, host); expect(set.bind(journal, host)).toBe(actions); @@ -1172,15 +1407,242 @@ describe("defineActions", () => { expect(journal.get(id)).toBeUndefined(); expect(outcomes).toEqual(["rejected"]); }); + + it("applies a fenced action under the connection that staged it", async () => { + const { actions, journal, host } = bind({ fence: "authority" }); + // A `CredentialRead` is structurally an `ActionFence`, so the staging read passes verbatim. + const read: CredentialRead = { identity: "id-a", generation: "gen-a" }; + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }, { fence: read }); + + await actions.apply(id, { generation: "gen-a" }); + expect(host.ran).toEqual(["one"]); + expect(journal.get(id)).toBeUndefined(); + }); + + it("terminally fails a fenced action whose connection was replaced", async () => { + const { actions, journal, host, outcomes } = bind({ fence: "authority" }); + const id = await actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "gen-a" } }); + + await expect(actions.apply(id, { generation: "gen-b" })) + .rejects.toThrow(/connection that has since been replaced/); + expect(host.ran).toEqual([]); + expect(journal.get(id)?.state).toBe("failed"); + expect(outcomes).toEqual(["failed"]); + + // Terminal: every later attempt answers with the same message and no provider call, leaving + // the user the move the message names. + await expect(actions.apply(id, { generation: "gen-b" })) + .rejects.toThrow(/connection that has since been replaced/); + expect(host.ran).toEqual([]); + }); + + it("stores only the generation a fence declares, never the whole read", async () => { + const { actions, journal } = bind({ fence: "authority" }); + const read: CredentialRead = { identity: "id-a", generation: "gen-a" }; + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }, { fence: read }); + + expect(journal.get(id)?.fence).toEqual({ generation: "gen-a" }); + }); + + it("terminally fails an unfenced record under an authority policy", async () => { + // Only a port reaches this: `submit` refuses an unfenced authority kind, but `upgradeRecord` + // cannot know what staged the rows it converts. Applying one would pin it to nothing. + const { actions, journal, host } = bind({ fence: "authority" }); + const id = journal.allocate({ kind: "execute", payload: { sql: "ported" } }); + + await expect(actions.apply(id, { generation: "gen-a" })) + .rejects.toThrow(/before this gatekeeper pinned actions to an account/); + + expect(host.ran).toEqual([]); + expect(journal.get(id)).toMatchObject({ state: "failed", undispatched: true }); + }); + + it("does not let a kind named after an Object member inherit a fence policy", async () => { + // `fenceOverrides.toString` on a plain object is `Object.prototype.toString` -- truthy, so a + // raw lookup would skip the set's own policy and read as neither "authority" nor "none", + // silently staging the action unfenced. + const set = defineActions<{ ran: string[] }, { toString: Sql }>({ + toString: { + delivery: "await-decision", + describe: () => presentation, + apply: async () => {}, + }, + }, { fence: "authority", fenceOverrides: {} }); + const journal = new ActionJournal>( + makeKv(), { namespace: "pending" }); + + await expect(set.bind(journal, { ran: [] }) + .submit(fakeQueue(), "toString", { sql: "one" })) + .rejects.toThrow(/is authority-fenced/); + }); + + it("releases a mismatched action's staging artifacts when the user rejects it", async () => { + const { actions, journal, host, outcomes } = bind({ fence: "authority" }); + const id = await actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "gen-a" } }); + + await expect(actions.apply(id, { generation: "gen-b" })) + .rejects.toThrow(/connection that has since been replaced/); + // The handler never ran, so the rejection the message asks for still owes its cleanup. + await actions.reject(id); + expect(host.ran).toEqual(["rejected one"]); + expect(journal.get(id)).toBeUndefined(); + expect(outcomes).toEqual(["failed", "rejected"]); + }); + + it("leaves a dispatched failure's cleanup to the handler that already ran", async () => { + const { actions, journal, host } = bind({ + // `ActionApplyError` means the effect is known absent, so the handler had already undone + // whatever it started -- it owns that cleanup, and reject only clears the record. + apply: async () => { throw new ActionApplyError("rolled back at the provider") }, + }); + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }); + + await expect(actions.apply(id)).rejects.toThrow("rolled back at the provider"); + await actions.reject(id); + expect(host.ran).toEqual([]); + expect(journal.get(id)).toBeUndefined(); + }); + + it("refuses a fenced action with no generation to compare, leaving it pending", async () => { + const { actions, journal, host } = bind({ fence: "authority" }); + const id = await actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "gen-a" } }); + + // A wiring bug, not a decision: the record must survive to be applied once apply() passes one. + await expect(actions.apply(id)).rejects.toThrow(/pass the current generation/); + expect(host.ran).toEqual([]); + expect(journal.get(id)?.state).toBe("pending"); + + await actions.apply(id, { generation: "gen-a" }); + expect(host.ran).toEqual(["one"]); + }); + + it("hands the handler the action-scoped git cache the overseer passed", async () => { + const seen: ActionContext[] = []; + const gitCache = {} as RpcStub; + const { actions } = bind({ apply: async (_payload, _host, ctx) => void seen.push(ctx) }); + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }); + + await actions.apply(id, { gitCache }); + expect(seen[0]?.gitCache).toBe(gitCache); + expect(seen[0]?.fence).toBeUndefined(); + }); + + it("refuses to stage a connection-fenced kind with no fence", async () => { + // The silent omission this policy exists to catch: unfenced, an action approved under one + // provider account applies cleanly under the next one. + const { actions, journal } = bind({ fence: "authority" }); + + await expect(actions.submit(fakeQueue(), "execute", { sql: "one" })) + .rejects.toThrow(/authority-fenced; stage it with/); + // Nothing staged, so no record is left behind for a later approval to find. + expect(journal.listPending()).toEqual([]); + }); + + it("refuses a fence on a kind declared authority-independent", async () => { + // Pinning an action nothing needed pinned makes it fail after an unrelated reconnect. + const { actions } = bind({ fence: "none" }); + + await expect(actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "gen-a" } })) + .rejects.toThrow(/declared authority-independent/); + }); + + it("lets one kind opt out of the set's policy, and holds every other to it", async () => { + const host: Host = { ran: [] }; + const journal = new ActionJournal>(makeKv(), { namespace: "pending" }); + const actions = defineActions({ + execute: { + delivery: "continue-with-simulation", + describe: () => presentation, + apply: async (payload, target) => void target.ran.push(payload.sql), + }, + publish: { + delivery: "await-decision", + describe: () => presentation, + apply: async (payload, target) => void target.ran.push(payload.page), + }, + }, { fence: "authority", fenceOverrides: { publish: "none" } }).bind(journal, host); + + // The override is named one kind at a time, so opting out is always a visible decision. + await expect(actions.submit(fakeQueue(), "publish", { page: "p" })).resolves.toBeGreaterThan(0); + await expect(actions.submit(fakeQueue(), "execute", { sql: "one" })) + .rejects.toThrow(/authority-fenced/); + }); + + it("fences on whatever authority the provider chose, not just a connection", async () => { + // The comparison is opaque equality, so a provider whose actions should survive + // re-authorization of the same account fences on a stable account id instead. Under a + // connection generation this action would have died at the re-auth. + const { actions, host } = bind({ fence: "authority" }); + const id = await actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "account-42" } }); + + await actions.apply(id, { generation: "account-42" }); + expect(host.ran).toEqual(["one"]); + }); + + it("refuses that same action once the account itself changes", async () => { + const { actions, journal } = bind({ fence: "authority" }); + const id = await actions.submit( + fakeQueue(), "execute", { sql: "one" }, { fence: { generation: "account-42" } }); + + await expect(actions.apply(id, { generation: "account-99" })) + .rejects.toThrow(/has since been replaced/); + expect(journal.get(id)?.state).toBe("failed"); + }); + + it("records an unknown outcome from an unclaimed definition, and says the guarantee was not held", + async () => { + // Non-replay rests on the pre-dispatch claim. Without one the record is still pending when + // the handler throws, so a dying activation leaves it replayable -- record the outcome, but + // do not let the gap pass silently. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { actions, journal } = bind({ + claimBeforeApply: false, + apply: async () => { throw new ActionOutcomeUnknownError("the request timed out"); }, + }); + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }); + + await expect(actions.apply(id)).rejects.toThrow("the request timed out"); + + expect(journal.get(id)).toMatchObject({ state: "failed", outcome: "unknown" }); + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it("stays quiet when the definition claimed before dispatch", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { actions } = bind({ + claimBeforeApply: true, + apply: async () => { throw new ActionOutcomeUnknownError("the request timed out"); }, + }); + const id = await actions.submit(fakeQueue(), "execute", { sql: "one" }); + + await expect(actions.apply(id)).rejects.toThrow("the request timed out"); + expect(logged).not.toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); }); describe("dependent actions", () => { type Actions = { create: { ref: string }; edit: { target: string } }; type Host = { ran: string[] }; - function bind(overrides: { apply?: () => Promise } = {}) { + function bind(overrides: { + apply?: () => Promise; + isResolvedReference?: (host: Host, ref: string) => boolean; + } = {}) { const host: Host = { ran: [] }; - const journal = new ActionJournal>(makeKv()); + const journal = new ActionJournal>(makeKv(), { namespace: "pending" }); const set = defineActions({ create: { delivery: "continue-with-simulation", @@ -1189,14 +1651,16 @@ describe("dependent actions", () => { provides: payload => [payload.ref], dependsOn: payload => (payload.ref.startsWith("child-") ? [payload.ref.slice(6)] : []), apply: overrides.apply ?? (async () => {}), + reject: async payload => void host.ran.push(`released ${payload.ref}`), }, edit: { delivery: "continue-with-simulation", describe: () => presentation, dependsOn: payload => [payload.target], apply: overrides.apply ?? (async () => {}), + reject: async payload => void host.ran.push(`released edit ${payload.target}`), }, - }); + }, { fence: "none", isResolvedReference: overrides.isResolvedReference ?? (() => false) }); return { host, journal, actions: set.bind(journal, host) }; } @@ -1207,6 +1671,23 @@ describe("dependent actions", () => { return id; } + it("refuses to apply an action whose provisional reference is unresolved", async () => { + const bound = new Set(); + const { actions, journal } = bind({ isResolvedReference: (_, ref) => bound.has(ref) }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const edit = queued(journal, { kind: "edit", payload: { target: "~1" } }); + + // Retryable and no cascade: the creation is still pending, so the dependent must survive to be + // applied after it — passing "~1" to the provider is what must not happen. + await expect(actions.apply(edit)).rejects.toThrow(/depends on ~1, which is not applied yet/); + expect(journal.get(edit)?.state).toBe("pending"); + + await actions.apply(create); + bound.add("~1"); + await actions.apply(edit); + expect(journal.get(edit)).toBeUndefined(); + }); + it("retires the actions a rejected creation strands, transitively", async () => { const { actions, journal } = bind(); const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); @@ -1221,11 +1702,26 @@ describe("dependent actions", () => { for (const id of [child, grandchild, edit]) { expect(journal.get(id)?.state).toBe("failed"); expect(journal.get(id)?.error) - .toBe(`This action needed action ${create}, which was not applied.`); + .toBe(`This action needed action ${create}, which did not complete.`); } expect(journal.listPending().map(({ id }) => id)).toEqual([unrelated]); }); + it("still owes a stranded dependent's cleanup when the user rejects it", async () => { + const { actions, journal, host } = bind(); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const edit = queued(journal, { kind: "edit", payload: { target: "~1" } }); + + await actions.reject(create); + expect(journal.get(edit)?.undispatched).toBe(true); + + // The dependent never reached its handler either, so the rejection it is left with still has + // to release what staging set up for it. + await actions.reject(edit); + expect(host.ran).toEqual(["released ~1", "released edit ~1"]); + expect(journal.get(edit)).toBeUndefined(); + }); + it("retires them for a terminal failure too, which no provider effect can resolve", async () => { const { actions, journal } = bind({ apply: async () => { throw new ActionApplyError("the provider refused"); }, @@ -1237,6 +1733,68 @@ describe("dependent actions", () => { expect(journal.get(edit)?.state).toBe("failed"); }); + it("spares a dependent whose reference the provider already bound", async () => { + // The create reached the provider, bound "~1", then failed configuring it. The reference + // exists, so the dependent is applicable -- retiring it would destroy viable work. + const bound = new Set(); + const { actions, journal } = bind({ + isResolvedReference: (_, ref) => bound.has(ref), + apply: async () => { + bound.add("~1"); + throw new ActionApplyError("created, then failed to configure"); + }, + }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const edit = queued(journal, { kind: "edit", payload: { target: "~1" } }); + + await expect(actions.apply(create)).rejects.toThrow("created, then failed to configure"); + expect(journal.get(create)?.state).toBe("failed"); + expect(journal.get(edit)?.state).toBe("pending"); + }); + + it("stops the cascade at a reference the provider already bound, not just the first", async () => { + // "child-~1" was bound by an apply that then failed retryably, so its record is pending again + // while the entity exists. Retiring "~1" must not reach the grandchild through it. + const bound = new Set(["child-~1"]); + const { actions, journal } = bind({ isResolvedReference: (_, ref) => bound.has(ref) }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const child = queued(journal, { kind: "create", payload: { ref: "child-~1" } }); + const grandchild = queued(journal, { kind: "edit", payload: { target: "child-~1" } }); + + await actions.reject(create); + + // The child still needed "~1", so it goes; the grandchild only needs an id that exists. + expect(journal.get(child)?.state).toBe("failed"); + expect(journal.get(grandchild)?.state).toBe("pending"); + }); + + it("strands only the references a terminal failure left unbound", async () => { + const { actions, journal } = bind({ + isResolvedReference: () => false, + apply: async () => { throw new ActionApplyError("the provider refused"); }, + }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const edit = queued(journal, { kind: "edit", payload: { target: "~1" } }); + + await expect(actions.apply(create)).rejects.toThrow("the provider refused"); + // The reason states what the scan established, not a claim about the provider call. + expect(journal.get(edit)?.error).toMatch(/did not complete/); + }); + + it("keeps every dependent when the handler reports an unknown outcome", async () => { + const { actions, journal } = bind({ + apply: async () => { throw new ActionOutcomeUnknownError("the request timed out"); }, + }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + const edit = queued(journal, { kind: "edit", payload: { target: "~1" } }); + + await expect(actions.apply(create)).rejects.toThrow("the request timed out"); + // Terminal for itself, so no replay can duplicate the effect... + expect(journal.get(create)).toMatchObject({ state: "failed", outcome: "unknown" }); + // ...but it asserts nothing about the entity the dependent names. + expect(journal.get(edit)?.state).toBe("pending"); + }); + it("leaves dependents decidable when a claim's outcome is unknown", async () => { // The stored answer says the effect may have landed, so "was not applied" cannot be asserted // over the dependents -- the dispatch may have created the very entity they name. @@ -1247,9 +1805,25 @@ describe("dependent actions", () => { journal.markClaimed(create); await expect(actions.apply(create)).rejects.toThrow(APPLY_OUTCOME_UNKNOWN_MESSAGE); + // The same classification a handler reaches by throwing `ActionOutcomeUnknownError`. + expect(journal.get(create)).toMatchObject({ state: "failed", outcome: "unknown" }); expect(journal.get(edit)?.state).toBe("pending"); }); + it("clears an unknown outcome on rejection without re-running the handler's cleanup", async () => { + // The handler ran and owns whatever it did at the provider, so the reject hook must not fire + // as it does for a record that never reached one. Rejecting is how the user clears the slot. + const { actions, journal, host } = bind({ + apply: async () => { throw new ActionOutcomeUnknownError("the request timed out"); }, + }); + const create = queued(journal, { kind: "create", payload: { ref: "~1" } }); + await expect(actions.apply(create)).rejects.toThrow("the request timed out"); + + await actions.reject(create); + expect(host.ran).toEqual([]); + expect(journal.get(create)).toBeUndefined(); + }); + it("leaves dependents alone while the creation can still be retried", async () => { const { actions, journal } = bind({ apply: async () => { throw new Error("provider unreachable"); }, diff --git a/packages/gatekeeper-kit/__tests__/auth-retry.test.ts b/packages/gatekeeper-kit/__tests__/auth-retry.test.ts index cff8fe897e..ea4df6c510 100644 --- a/packages/gatekeeper-kit/__tests__/auth-retry.test.ts +++ b/packages/gatekeeper-kit/__tests__/auth-retry.test.ts @@ -8,7 +8,7 @@ describe("withAuthRetry", () => { const getToken = vi.fn(async () => "current"); const run = vi.fn(async (token: string) => `${token}-result`); - expect(await withAuthRetry({ getToken, isAuthError: () => false }, run)) + expect(await withAuthRetry({ getToken, isAuthError: () => false, replayable: true }, run)) .toBe("current-result"); expect(getToken).toHaveBeenCalledOnce(); expect(getToken).toHaveBeenCalledWith({ forceRefresh: false }); @@ -20,7 +20,7 @@ describe("withAuthRetry", () => { const getToken = vi.fn(async () => "current"); const run = vi.fn(async () => { throw failure; }); - await expect(withAuthRetry({ getToken, isAuthError: () => false }, run)) + await expect(withAuthRetry({ getToken, isAuthError: () => false, replayable: true }, run)) .rejects.toBe(failure); expect(getToken).toHaveBeenCalledOnce(); }); @@ -34,7 +34,8 @@ describe("withAuthRetry", () => { return "accepted"; }); - expect(await withAuthRetry({ getToken, isAuthError: error => error === authError }, run)) + expect(await withAuthRetry( + { getToken, isAuthError: error => error === authError, replayable: true }, run)) .toBe("accepted"); expect(getToken).toHaveBeenNthCalledWith(2, { forceRefresh: true, @@ -52,10 +53,13 @@ describe("withAuthRetry", () => { throw token === "stale" ? firstError : secondError; }); - // Reporting it belongs to `CredentialSource.run`, which holds the identity to fence on. + // Reporting is out of scope here: `CredentialSource.run(operation, { replayable: true })` + // owns the retry-then-report flow, with the account healing inside the rejection + // adjudication. await expect(withAuthRetry({ getToken, isAuthError: error => error === firstError || error === secondError, + replayable: true, }, run)).rejects.toBe(secondError); }); @@ -71,6 +75,7 @@ describe("withAuthRetry", () => { await expect(withAuthRetry({ getToken, isAuthError: error => error === authError, + replayable: true, }, run)).rejects.toBe(providerError); }); }); diff --git a/packages/gatekeeper-kit/__tests__/cache.test.ts b/packages/gatekeeper-kit/__tests__/cache.test.ts index 0504139e39..5ae5b71c32 100644 --- a/packages/gatekeeper-kit/__tests__/cache.test.ts +++ b/packages/gatekeeper-kit/__tests__/cache.test.ts @@ -1,18 +1,36 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { KvTtlCache, type AuthoritySource, type CacheKv } from "../src/cache"; -import { CredentialSource } from "../src/credentials"; +import { CredentialsExpiredError, CredentialSource } from "../src/credentials"; import { fakeKv } from "./fake-kv"; function makeKv(): CacheKv { return fakeKv(); } +function connectedSource() { + const account = { identity: "id-a", generation: "gen-a", connected: true }; + const getCredentials = vi.fn(async () => { + if (!account.connected) throw new Error("account not connected"); + return { + creds: { token: "live" }, + identity: account.identity, + generation: account.generation, + }; + }); + const source = new CredentialSource<{ token: string }>({ + account: () => ({ getCredentials, reportCredentialsRejected: async () => "expired" as const }), + isAuthError: error => error instanceof Error && error.message === "401", + expiredMessage: "Reconnect.", + }); + return { source, account, getCredentials }; +} + afterEach(() => void vi.useRealTimers()); describe("KvTtlCache", () => { it("loads once, then serves the entry until its TTL elapses", async () => { vi.useFakeTimers(); - const cache = new KvTtlCache(makeKv(), () => "authority"); + const cache = new KvTtlCache(makeKv(), () => "authority", { legacyUnnamed: true }); const load = vi.fn(async () => ({ name: "acme" })); expect(await cache.cached("project", 1000, load)).toEqual({ name: "acme" }); @@ -25,8 +43,60 @@ describe("KvTtlCache", () => { expect(load).toHaveBeenCalledTimes(2); }); + it("dates the entry from the load, not from the fence read that follows it", async () => { + // `cacheAuthority()` is a live account read that can itself refresh credentials. Stamping the + // entry when that returns would hand the caller the whole TTL again on top of the wait. + vi.useFakeTimers(); + const fence = Promise.withResolvers(); + let reads = 0; + const cache = new KvTtlCache(makeKv(), async () => { + // The entry read runs first; only the one after `load()` is parked. + if (++reads === 2) await fence.promise; + return "authority"; + }, { legacyUnnamed: true }); + + const loading = cache.cached("project", 60_000, async () => "loaded"); + // The load has resolved and the fence read is parked; the clock runs while it waits. + await vi.advanceTimersByTimeAsync(30_000); + fence.resolve(); + expect(await loading).toBe("loaded"); + + // 30s of the 60s window went to the fence read, so the entry expires 30s from now, not 60s. + await vi.advanceTimersByTimeAsync(31_000); + const reload = vi.fn(async () => "reloaded"); + expect(await cache.cached("project", 60_000, reload)).toBe("reloaded"); + }); + + it("coalesces one key across instances over the same storage", async () => { + // A facet that builds its cache per call has two instances over one namespace. Coalescing per + // instance would let both load, and the slower one overwrite the newer entry afterwards. + const kv = makeKv(); + const cache = () => new KvTtlCache(kv, () => "authority", { name: "projects" }); + const load = vi.fn(async () => "loaded"); + + const both = await Promise.all([ + cache().cached("project", 60_000, load), + cache().cached("project", 60_000, load), + ]); + + expect(both).toEqual(["loaded", "loaded"]); + expect(load).toHaveBeenCalledOnce(); + }); + + it("keeps two named caches over one storage from sharing a load", async () => { + const kv = makeKv(); + const projects = new KvTtlCache(kv, () => "authority", { name: "projects" }); + const issues = new KvTtlCache(kv, () => "authority", { name: "issues" }); + + // Concurrent, so a load key missing the cache's own prefix would collapse them into one. + expect(await Promise.all([ + projects.cached("a", 60_000, async () => "from projects"), + issues.cached("a", 60_000, async () => "from issues"), + ])).toEqual(["from projects", "from issues"]); + }); + it("reloads every entry after invalidating all", async () => { - const cache = new KvTtlCache(makeKv(), () => "authority"); + const cache = new KvTtlCache(makeKv(), () => "authority", { legacyUnnamed: true }); await cache.cached("a", 60_000, async () => 1); await cache.cached("b", 60_000, async () => 2); @@ -39,10 +109,14 @@ describe("KvTtlCache", () => { }); it("does not store a value invalidated during a load", async () => { - const cache = new KvTtlCache(makeKv(), () => "authority"); + const cache = new KvTtlCache(makeKv(), () => "authority", { legacyUnnamed: true }); const { promise, resolve } = Promise.withResolvers(); + const load = vi.fn(() => promise); - const loading = cache.cached("schema", 60_000, () => promise); + const loading = cache.cached("schema", 60_000, load); + // Inside the load, not before it: the authority read precedes it, so an invalidation landing + // earlier is one this load already reflects. + await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); cache.invalidateAll(); resolve(1); @@ -56,7 +130,7 @@ describe("KvTtlCache", () => { // Pre-first-credential-fetch: serving or storing here could cross principals. const kv = makeKv(); let authority: string | undefined = "a"; - const cache = new KvTtlCache(kv, () => authority); + const cache = new KvTtlCache(kv, () => authority, { legacyUnnamed: true }); await cache.cached("project", 60_000, async () => "from a"); authority = undefined; @@ -74,7 +148,7 @@ describe("KvTtlCache", () => { it("does not store a load whose authority became unknown mid-flight", async () => { const kv = makeKv(); let authority: string | undefined = "a"; - const cache = new KvTtlCache(kv, () => authority); + const cache = new KvTtlCache(kv, () => authority, { legacyUnnamed: true }); const { promise, resolve } = Promise.withResolvers(); const loading = cache.cached("project", 60_000, () => promise); @@ -88,8 +162,8 @@ describe("KvTtlCache", () => { it("does not serve an entry written under another authority", async () => { const kv = makeKv(); - const authorityA = new KvTtlCache(kv, () => "a"); - const authorityB = new KvTtlCache(kv, () => "b"); + const authorityA = new KvTtlCache(kv, () => "a", { legacyUnnamed: true }); + const authorityB = new KvTtlCache(kv, () => "b", { legacyUnnamed: true }); await authorityA.cached("project", 60_000, async () => "from a"); const load = vi.fn(async () => "from b"); @@ -97,12 +171,54 @@ describe("KvTtlCache", () => { expect(load).toHaveBeenCalledOnce(); }); + it("keeps named caches over one storage from colliding or invalidating each other", async () => { + // Two logical families with a natural key in common: unnamed, each would serve the other's + // value on a hit, and either one's invalidateAll would clear both. + const kv = makeKv(); + const issues = new KvTtlCache(kv, () => "authority", { name: "issues" }); + const pages = new KvTtlCache(kv, () => "authority", { name: "pages" }); + + expect(await issues.cached("home", 60_000, async () => "issue")).toBe("issue"); + expect(await pages.cached("home", 60_000, async () => "page")).toBe("page"); + + issues.invalidateAll(); + expect(await issues.cached("home", 60_000, async () => "issue again")).toBe("issue again"); + expect(await pages.cached("home", 60_000, async () => "page again")).toBe("page"); + }); + + it("keeps a named cache clear of the unnamed layout ports already have in storage", async () => { + const kv = makeKv(); + const ported = new KvTtlCache(kv, () => "authority", { legacyUnnamed: true }); + // "entry" is the name that would collide without the sigil: `cache:entry:generation` is the + // unnamed cache's own entry for the key "generation", and `cache:entry:entry:home` is its + // entry for "entry:home". + const named = new KvTtlCache(kv, () => "authority", { name: "entry" }); + + expect(await ported.cached("home", 60_000, async () => "legacy")).toBe("legacy"); + expect(await ported.cached("generation", 60_000, async () => "counter-shaped")).toBe( + "counter-shaped"); + expect(await named.cached("home", 60_000, async () => "named")).toBe("named"); + named.invalidateAll(); + + // Both survive the other's writes, and the unnamed layout is byte-for-byte what ports have. + expect(await ported.cached("home", 60_000, async () => "legacy again")).toBe("legacy"); + expect(await ported.cached("generation", 60_000, async () => "again")).toBe("counter-shaped"); + expect(kv.get("cache:entry:home")).toBeDefined(); + expect(kv.get("cache:@entry:entry:home")).toBeDefined(); + }); + + it("refuses a name that would not survive the key it is spliced into", () => { + for (const name of ["", "has:colon", "spaced name"]) { + expect(() => new KvTtlCache(makeKv(), () => "authority", { name })).toThrow(/Cache name/); + } + }); + it("follows a reconnect under one live instance, in both directions", async () => { // The two-instance case above passes with an authority captured at construction; an in-place // reconnect, which replaces the grant while this cache stays alive, does not. const kv = makeKv(); let authority = "a"; - const cache = new KvTtlCache(kv, () => authority); + const cache = new KvTtlCache(kv, () => authority, { legacyUnnamed: true }); await cache.cached("project", 60_000, async () => "from a"); authority = "b"; @@ -116,7 +232,7 @@ describe("KvTtlCache", () => { it("discards a value whose authority was replaced during the load", async () => { const kv = makeKv(); let authority = "a"; - const cache = new KvTtlCache(kv, () => authority); + const cache = new KvTtlCache(kv, () => authority, { legacyUnnamed: true }); const { promise, resolve } = Promise.withResolvers(); const loading = cache.cached("project", 60_000, () => promise); @@ -134,7 +250,7 @@ describe("KvTtlCache", () => { it("does not share an in-flight load across a reconnect", async () => { const kv = makeKv(); let authority = "a"; - const cache = new KvTtlCache(kv, () => authority); + const cache = new KvTtlCache(kv, () => authority, { legacyUnnamed: true }); const { promise, resolve } = Promise.withResolvers(); const underA = cache.cached("project", 60_000, () => promise); @@ -148,20 +264,20 @@ describe("KvTtlCache", () => { }); it("coalesces concurrent loads for one key", async () => { - const cache = new KvTtlCache(makeKv(), () => "authority"); + const cache = new KvTtlCache(makeKv(), () => "authority", { legacyUnnamed: true }); const { promise, resolve } = Promise.withResolvers(); const load = vi.fn(() => promise); const first = cache.cached("project", 60_000, load); const second = cache.cached("project", 60_000, load); - expect(load).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); resolve(1); await expect(Promise.all([first, second])).resolves.toEqual([1, 1]); }); it("refuses a ttl that would silently disable or freeze the entry", async () => { - const cache = new KvTtlCache(makeKv(), () => "authority"); + const cache = new KvTtlCache(makeKv(), () => "authority", { legacyUnnamed: true }); const load = vi.fn(async () => 1); // `Infinity` is the dangerous one: it never expires, so a stale entry is served for good. @@ -173,97 +289,126 @@ describe("KvTtlCache", () => { }); describe("KvTtlCache.partitionedBy", () => { - it("follows the source's authority: hit, bypass while unknown, miss after a change", async () => { - let authority: string | undefined = "gen-a"; - const source: AuthoritySource = { authority: () => authority }; - const cache = KvTtlCache.partitionedBy(makeKv(), source); + it("repartitions on a reconnect no fetch has observed yet", async () => { + let generation = "gen-a"; + const source: AuthoritySource = { cacheAuthority: async () => generation }; + const cache = KvTtlCache.partitionedBy(makeKv(), source, { legacyUnnamed: true }); const load = vi.fn(async () => "from a"); expect(await cache.cached("project", 60_000, load)).toBe("from a"); expect(await cache.cached("project", 60_000, load)).toBe("from a"); expect(load).toHaveBeenCalledOnce(); - authority = undefined; - expect(await cache.cached("project", 60_000, async () => "unpartitioned")) - .toBe("unpartitioned"); - - authority = "gen-b"; + // A last-seen partition would have served "from a" for the rest of the entry's TTL. + generation = "gen-b"; expect(await cache.cached("project", 60_000, async () => "from b")).toBe("from b"); }); - function connectedSource() { - const account = { identity: "id-a", generation: "gen-a" }; - const source = new CredentialSource<{ token: string }>({ - account: () => ({ - getCredentials: async () => - ({ creds: { token: "live" }, identity: account.identity, generation: account.generation }), - noteCredentialsExpired: async () => {}, - }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); - return { source, account }; - } + it("returns a load whose fence moved during it, without caching the value", async () => { + let generation = "gen-a"; + const kv = fakeKv(); + const cache = KvTtlCache.partitionedBy(kv, { cacheAuthority: async () => generation }, { legacyUnnamed: true }); + const { promise, resolve } = Promise.withResolvers(); + + const loading = cache.cached("project", 60_000, () => promise); + generation = "gen-b"; + resolve("from a"); + + expect(await loading).toBe("from a"); + expect(kv.keys()).toEqual([]); + }); + + it("caches nothing when an invalidation lands during the post-load fence read", async () => { + // The generation must be read after that await. Read before it, this value would be written + // under a generation the invalidation had already retired. + const kv = fakeKv(); + const parked = Promise.withResolvers(); + let reads = 0; + const cache = KvTtlCache.partitionedBy(kv, { + cacheAuthority: async () => { + if (++reads === 2) await parked.promise; + return "gen-a"; + }, + }, { legacyUnnamed: true }); + + const loading = cache.cached("project", 60_000, async () => "loaded"); + await vi.waitFor(() => expect(reads).toBe(2)); + cache.invalidateAll(); + parked.resolve(); - it("partitions by a real source's connection across expiry and reconnect", async () => { + expect(await loading).toBe("loaded"); + expect(kv.keys()).toEqual(["cache:generation"]); + }); + + it("partitions a real source by the connection its account fences reads under", async () => { const { source, account } = connectedSource(); - const cache = KvTtlCache.partitionedBy(makeKv(), source); + const cache = KvTtlCache.partitionedBy(makeKv(), source, { legacyUnnamed: true }); const load = vi.fn(async () => "from a"); - // A fetch establishes the partition, and reads under it hit. - await source.get(); expect(await cache.cached("project", 60_000, load)).toBe("from a"); expect(await cache.cached("project", 60_000, load)).toBe("from a"); expect(load).toHaveBeenCalledOnce(); - // A reported expiry drops the partition: the cache bypasses rather than serves the dead grant. - await expect(source.run(async () => { throw new Error("401"); })) - .rejects.toThrow("Reconnect the account."); - expect(await cache.cached("project", 60_000, async () => "unpartitioned")) - .toBe("unpartitioned"); - - // The account rotates on reconnect; the next fetch moves the cache to the new partition, so - // the old principal's entries are misses. + // An in-place reconnect with no fetch in between: the hit reads the fence, so it misses. account.identity = "id-b"; account.generation = "gen-b"; - await source.get(); expect(await cache.cached("project", 60_000, async () => "from b")).toBe("from b"); }); - it("keeps bypassing when a refetch returns the dead grant", async () => { - const { source, account } = connectedSource(); - const cache = KvTtlCache.partitionedBy(makeKv(), source); + it("shares one account credential read between concurrent hits", async () => { + const { source, getCredentials } = connectedSource(); + const cache = KvTtlCache.partitionedBy(makeKv(), source, { legacyUnnamed: true }); + await cache.cached("project", 60_000, async () => "from a"); + getCredentials.mockClear(); + const load = vi.fn(async () => "reloaded"); + + expect(await Promise.all([ + cache.cached("project", 60_000, load), + cache.cached("project", 60_000, load), + ])).toEqual(["from a", "from a"]); + expect(getCredentials).toHaveBeenCalledOnce(); + expect(load).not.toHaveBeenCalled(); + }); - await source.get(); + it("bypasses cached data while the source refuses to vouch for an expired grant", async () => { + const { source, account } = connectedSource(); + const cache = KvTtlCache.partitionedBy(makeKv(), source, { legacyUnnamed: true }); expect(await cache.cached("project", 60_000, async () => "from a")).toBe("from a"); - await expect(source.run(async () => { throw new Error("401"); })) - .rejects.toThrow("Reconnect the account."); - // The account keeps the grant until reconnect, so the refetch returns the same identity; - // adopting its generation would let hit-only paths serve the dead partition unchecked. - await source.get(); - expect(await cache.cached("project", 60_000, async () => "bypassed")).toBe("bypassed"); + // The account may keep serving a dead grant until reconnect. The source already knows that + // identity is dead, so a cache hit must not hide the outage for the rest of the entry's TTL. + await expect(source.run(async () => { throw new Error("401"); })) + .rejects.toThrow(CredentialsExpiredError); + const load = vi.fn(async () => "reloaded"); + expect(await cache.cached("project", 60_000, load)).toBe("reloaded"); + expect(load).toHaveBeenCalledOnce(); account.identity = "id-b"; account.generation = "gen-b"; - await source.get(); expect(await cache.cached("project", 60_000, async () => "from b")).toBe("from b"); }); - it("serves the last-seen partition until a fetch observes a reconnect", async () => { + it("propagates a disconnected account rather than serving or bypassing", async () => { const { source, account } = connectedSource(); - const cache = KvTtlCache.partitionedBy(makeKv(), source); + const cache = KvTtlCache.partitionedBy(makeKv(), source, { legacyUnnamed: true }); + const load = vi.fn(async () => "from a"); - await source.get(); - expect(await cache.cached("project", 60_000, async () => "from a")).toBe("from a"); + account.connected = false; + await expect(cache.cached("project", 60_000, load)).rejects.toThrow("account not connected"); + expect(load).not.toHaveBeenCalled(); + }); - // A silent in-place reconnect with no fetch since: the authority is last-seen, so the old - // partition keeps hitting until the next credential read — the accepted TTL-bounded window. - account.identity = "id-b"; - account.generation = "gen-b"; - expect(await cache.cached("project", 60_000, async () => "unseen")).toBe("from a"); + it("returns a load the account can no longer vouch for, uncached", async () => { + const { source, account } = connectedSource(); + const kv = fakeKv(); + const cache = KvTtlCache.partitionedBy(kv, source, { legacyUnnamed: true }); - await source.get(); - expect(await cache.cached("project", 60_000, async () => "from b")).toBe("from b"); + const loaded = await cache.cached("project", 60_000, async () => { + account.connected = false; + return "from a"; + }); + + expect(loaded).toBe("from a"); + expect(kv.keys()).toEqual([]); }); }); diff --git a/packages/gatekeeper-kit/__tests__/credentials.test.ts b/packages/gatekeeper-kit/__tests__/credentials.test.ts index a573a80d0e..aeab885a46 100644 --- a/packages/gatekeeper-kit/__tests__/credentials.test.ts +++ b/packages/gatekeeper-kit/__tests__/credentials.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it, vi } from "vitest"; import { + ConnectionSupersededError, CredentialCoordinator, + CredentialsChangedError, CredentialsExpiredError, CredentialSource, + isConnectionSuperseded, + isCredentialsChanged, + isCredentialsExpired, type CredentialCoordinatorOptions, type CredentialsKv, type CredentialSourceOptions, type CredentialsWithIdentity, + type CredentialRead, + type RejectionVerdict, } from "../src/credentials"; +import { notifyCredentialsExpiredOnce } from "../src/credential-expiry"; import { fakeKv } from "./fake-kv"; type Creds = { token: string; expiresAt: number }; @@ -28,6 +36,39 @@ function coordinator( const live: Creds = { token: "live", expiresAt: Date.now() + 60 * 60 * 1000 }; const stale: Creds = { token: "stale", expiresAt: Date.now() + 1000 }; +const notifyless = { notify: async () => {} }; + +/** A refresh whose provider answers that the grant itself is dead. */ +const dead = async (): Promise => { + throw new CredentialsExpiredError("invalid_grant"); +}; + +/** A notify that stalls until released, resolving `entered` once the notification is in flight. */ +function stallingNotify() { + const entered = Promise.withResolvers(); + const stalled = Promise.withResolvers(); + return { + notify: () => { entered.resolve(); return stalled.promise; }, + entered: entered.promise, + release: stalled.resolve, + }; +} + +/** Starts a run whose 401 stalls until released, resolving once the operation has entered. */ +async function stalledRun( + instance: CredentialSource, options: { replayable?: boolean } = {}, +) { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const run = instance.run(async () => { + entered.resolve(); + await gate.promise; + throw new Error("401"); + }, options); + await entered.promise; + return { run, release: gate.resolve }; +} + describe("CredentialCoordinator", () => { it("reports expiry when nothing is stored", async () => { await expect(coordinator(makeKv()).fresh(async () => live)) @@ -159,6 +200,60 @@ describe("CredentialCoordinator", () => { expect(instance.connectionGeneration()).not.toBe(connected); }); + it("stores a fenced connect while the attempt's connection still stands", async () => { + const instance = coordinator(makeKv()); + const startedUnder = instance.connectionGeneration(); + + instance.connect(live, { ifGeneration: startedUnder }); + + expect(instance.stored()).toEqual(live); + // Still rotates: the attempt that won defines the new connection. + expect(instance.connectionGeneration()).not.toBe(startedUnder); + }); + + it("refuses a fenced connect the account moved past, storing nothing", async () => { + // The window `claimOAuth` cannot cover: the nonce is consumed, then the provider exchange runs, + // and a revoke lands inside it. Unfenced, the older completion overwrites the revoke. + const instance = coordinator(makeKv()); + instance.connect(stale); + const startedUnder = instance.connectionGeneration(); + instance.clear(); + + expect(() => instance.connect(live, { ifGeneration: startedUnder })) + .toThrow(ConnectionSupersededError); + expect(instance.stored()).toBeUndefined(); + }); + + it("refuses a fenced connect a newer reconnect already completed", async () => { + const instance = coordinator(makeKv()); + const startedUnder = instance.connectionGeneration(); + // A second attempt started later and finished first. + instance.connect(live); + + expect(() => instance.connect(stale, { ifGeneration: startedUnder })) + .toThrow(ConnectionSupersededError); + // The winner's credentials stand; the straggler's are the caller's to dispose. + expect(instance.stored()).toEqual(live); + }); + + it("marks the refusal so it survives a transport that rebuilds errors", () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + + let thrown: unknown; + try { + instance.connect(live, { ifGeneration: "never-was" }); + } catch (error) { + thrown = error; + } + + expect(isConnectionSuperseded(thrown)).toBe(true); + // Rebuilt the way capnweb delivers it: the class and the name are gone, the own `code` rides. + const rebuilt = Object.assign(new Error("superseded"), { code: "ConnectionSupersededError" }); + expect(isConnectionSuperseded(rebuilt)).toBe(true); + expect(isConnectionSuperseded(new Error("unrelated"))).toBe(false); + }); + it("lets a reconnect landing mid-refresh win", async () => { const instance = coordinator(makeKv()); instance.connect(stale); @@ -172,6 +267,46 @@ describe("CredentialCoordinator", () => { expect(instance.stored()?.token).toBe("reconnected"); }); + it("hands a mint a revoke fenced out to the provider for disposal", async () => { + const discarded: Creds[] = []; + const instance = new CredentialCoordinator(makeKv(), { + expiresAt: creds => creds.expiresAt, + discardMint: mint => void discarded.push(mint), + }); + instance.connect(stale); + const { promise, resolve } = Promise.withResolvers(); + + const refreshing = instance.fresh(() => promise); + instance.clear(); + resolve({ token: "orphaned", expiresAt: live.expiresAt }); + + await expect(refreshing).rejects.toThrow(CredentialsExpiredError); + // Nothing will ever store this mint, so with a rotating grant chain the provider-side revoke + // is the only thing that stops its refresh token working. + expect(discarded.map(creds => creds.token)).toEqual(["orphaned"]); + }); + + it("logs a failing discard handler without disturbing the refresh result", async () => { + const logged = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const instance = new CredentialCoordinator(makeKv(), { + expiresAt: creds => creds.expiresAt, + discardMint: async () => { throw new Error("revoke endpoint down") }, + }); + instance.connect(stale); + const { promise, resolve } = Promise.withResolvers(); + + const refreshing = instance.fresh(() => promise); + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + resolve({ token: "orphaned", expiresAt: live.expiresAt }); + + expect((await refreshing).token).toBe("reconnected"); + expect(logged).toHaveBeenCalledOnce(); + } finally { + logged.mockRestore(); + } + }); + it("reports expiry when a revoke lands mid-refresh", async () => { const instance = coordinator(makeKv()); instance.connect(stale); @@ -475,24 +610,650 @@ describe("CredentialCoordinator", () => { it("refuses to declare a legacy key the coordinator owns", () => { // Sweeping the whole `credentials:` namespace would take the identity with it, and an - // unfenceable "" would then let an in-flight refresh commit over a revoke. - expect(() => coordinator(makeKv(), undefined, ["accessToken", "credentials:identity"])) - .toThrow('Legacy key "credentials:identity" is one the coordinator owns.'); + // unfenceable "" would then let an in-flight refresh commit over a revoke; reaping the death + // marker would resurrect a grant the account already buried. + for (const owned of ["credentials:identity", "credentials:expired"]) { + expect(() => coordinator(makeKv(), undefined, ["accessToken", owned])) + .toThrow(`Legacy key "${owned}" is one the coordinator owns.`); + } + }); + + describe("snapshot", () => { + it("returns a coherent triple of the stored credentials", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + + const read = await instance.snapshot(async () => live); + expect(read).toEqual({ + creds: live, + identity: instance.identity(), + generation: instance.connectionGeneration(), + }); + expect(read.identity).toMatch(/^[0-9a-f]{64}$/); + }); + + it("keeps the triple coherent against a connect landing mid-refresh", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const { promise, resolve } = Promise.withResolvers(); + + const reading = instance.snapshot(() => promise); + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + const identity = instance.identity(); + const generation = instance.connectionGeneration(); + resolve({ token: "refreshed", expiresAt: live.expiresAt }); + + // The reconnect won the refresh; the triple must be its credentials under its identity and + // generation, never the refresh result under the reconnect's fence. + expect(await reading).toEqual({ + creds: { token: "reconnected", expiresAt: live.expiresAt }, + identity, + generation, + }); + }); + + it("notifies the Workshop before rethrowing a confirmed expiry of the stored grant", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const notify = vi.fn(async () => {}); + + await expect(instance.snapshot(async () => { + throw new CredentialsExpiredError("invalid_grant"); + }, { notify })).rejects.toThrow("invalid_grant"); + expect(notify).toHaveBeenCalledOnce(); + + // A notify that throws is logged account-side; the caller still gets the expiry verdict. + // A fresh grant, since the buried one now refuses before it reaches the provider. + instance.connect(stale); + const logged = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(instance.snapshot(async () => { + throw new CredentialsExpiredError("invalid_grant"); + }, { notify: async () => { throw new Error("workshop unreachable"); } })) + .rejects.toThrow("invalid_grant"); + } finally { + logged.mockRestore(); + } + }); + + it("serves a reconnect that lands mid-notify instead of the stale death", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const { notify, entered, release } = stallingNotify(); + + const reading = instance.snapshot(async () => { + throw new CredentialsExpiredError("invalid_grant"); + }, { notify }); + await entered; + instance.connect(live); + release(); + + expect(await reading).toEqual({ + creds: live, + identity: instance.identity(), + generation: instance.connectionGeneration(), + }); + }); + + it("keeps the death's provenance when a disconnect lands mid-notify", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const { notify, entered, release } = stallingNotify(); + + const reading = instance.snapshot(async () => { + throw new CredentialsExpiredError("invalid_grant"); + }, { notify }); + await entered; + instance.clear(); + release(); + + // The disconnect moved the fence like a reconnect would, but nothing replaced the grant: + // still expiry, chaining the death instead of fabricating a causeless one. + const thrown = await reading.then(() => undefined, (error: unknown) => error); + expect(thrown).toBeInstanceOf(CredentialsExpiredError); + expect((thrown as Error).message).toBe("This account is not connected."); + expect(((thrown as Error).cause as Error).message).toBe("invalid_grant"); + }); + + it("never notifies for a disconnect", async () => { + const notify = vi.fn(async () => {}); + // Nothing stored: reading a disconnected account is not grant death. + await expect(coordinator(makeKv()).snapshot(async () => live, { notify })) + .rejects.toThrow(CredentialsExpiredError); + + // A revoke mid-refresh is the user's own action; announcing expiry would misattribute it. + const instance = coordinator(makeKv()); + instance.connect(stale); + const { promise, resolve } = Promise.withResolvers(); + const reading = instance.snapshot(() => promise, { notify }); + instance.clear(); + resolve({ token: "refreshed", expiresAt: live.expiresAt }); + await expect(reading).rejects.toThrow(CredentialsExpiredError); + + expect(notify).not.toHaveBeenCalled(); + }); + }); + + describe("adjudicateRejection", () => { + it("answers superseded for an identity that is no longer current, before any heal", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + const refresh = vi.fn(async () => live); + const notify = vi.fn(async () => {}); + + await expect(instance.adjudicateRejection("someone-elses-fence", { refresh, notify })) + .resolves.toBe("superseded"); + expect(refresh).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it('never matches "" against a never-connected account', async () => { + // A never-connected read carries identity ""; the account's own identity() is also "". An + // equality gate alone would heal — or expire — an account that was never connected. + const notify = vi.fn(async () => {}); + await expect(coordinator(makeKv()).adjudicateRejection("", { notify })) + .resolves.toBe("superseded"); + expect(notify).not.toHaveBeenCalled(); + }); + + it("expires a current identity on a grant-death provider, notifying first", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + const order: string[] = []; + const notify = vi.fn(async () => { order.push("notify"); }); + + const verdict = await instance.adjudicateRejection(instance.identity(), { notify }); + order.push("verdict"); + expect(verdict).toBe("expired"); + expect(order).toEqual(["notify", "verdict"]); + expect(instance.stored()).toEqual(live); + }); + + it("heals a current rejected identity and answers superseded", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const notify = vi.fn(async () => {}); + const refresh = vi.fn(async () => ({ token: "minted", expiresAt: live.expiresAt })); + + await expect(instance.adjudicateRejection(instance.identity(), { refresh, notify })) + .resolves.toBe("superseded"); + expect(instance.stored()?.token).toBe("minted"); + expect(notify).not.toHaveBeenCalled(); + }); + + it("expires the grant when the heal confirms its death, keeping the verdict past a failed notify", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const refresh = async (): Promise => { + throw new CredentialsExpiredError("invalid_grant"); + }; + const notify = vi.fn(async () => {}); + + await expect(instance.adjudicateRejection(instance.identity(), { refresh, notify })) + .resolves.toBe("expired"); + expect(notify).toHaveBeenCalledOnce(); + + // A throwing notify is the account's own trouble, never a different verdict. + const logged = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(instance.adjudicateRejection(instance.identity(), { + refresh, notify: async () => { throw new Error("workshop unreachable"); }, + })).resolves.toBe("expired"); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + { death: "a grant-death verdict", refresh: undefined }, + { death: "a dead mint's verdict", + refresh: async () => { throw new CredentialsExpiredError("invalid_grant"); } }, + ])("supersedes $death when a reconnect lands mid-notify", async ({ refresh }) => { + const instance = coordinator(makeKv()); + instance.connect(live); + const { notify, entered, release } = stallingNotify(); + + const verdict = instance.adjudicateRejection(instance.identity(), { refresh, notify }); + await entered; + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + release(); + + await expect(verdict).resolves.toBe("superseded"); + }); + + it.each([ + { death: "a grant-death verdict", refresh: undefined }, + { death: "a dead mint's verdict", + refresh: async () => { throw new CredentialsExpiredError("invalid_grant"); } }, + ])("keeps $death expired when a disconnect lands mid-notify", async ({ refresh }) => { + const instance = coordinator(makeKv()); + instance.connect(live); + const { notify, entered, release } = stallingNotify(); + + const verdict = instance.adjudicateRejection(instance.identity(), { refresh, notify }); + await entered; + instance.clear(); + release(); + + // "Superseded" promises a successor; the disconnect left none to re-enter into. + await expect(verdict).resolves.toBe("expired"); + }); + + it("expires a rejected identity a disconnect moved past, without notifying", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + const rejected = instance.identity(); + const notify = vi.fn(async () => {}); + instance.clear(); + + await expect(instance.adjudicateRejection(rejected, { notify })).resolves.toBe("expired"); + expect(notify).not.toHaveBeenCalled(); + }); + + it("expires the rejection when a disconnect lands during a failing mint", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const rejected = instance.identity(); + const notify = vi.fn(async () => {}); + const mint = Promise.withResolvers(); + + const adjudicating = instance.adjudicateRejection(rejected, { + refresh: () => mint.promise, notify, + }); + instance.clear(); + mint.reject(new Error("502 from token endpoint")); + + // Neither "unavailable" nor "superseded" helps a caller whose account is gone. + await expect(adjudicating).resolves.toBe("expired"); + expect(notify).not.toHaveBeenCalled(); + }); + + it("answers unavailable when the heal fails for non-credential reasons", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const notify = vi.fn(async () => {}); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(instance.adjudicateRejection(instance.identity(), { + refresh: async () => { throw new Error("502 from token endpoint"); }, + notify, + })).resolves.toBe("unavailable"); + } finally { + logged.mockRestore(); + } + + // Nothing adjudicated: the grant is intact and no expiry was announced. + expect(instance.stored()).toEqual(stale); + expect(notify).not.toHaveBeenCalled(); + }); + + it("lets a reconnect racing the heal win as superseded, even when the mint dies", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const rejected = instance.identity(); + const notify = vi.fn(async () => {}); + const mint = Promise.withResolvers(); + + const adjudicating = instance.adjudicateRejection(rejected, { + refresh: () => mint.promise, notify, + }); + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + mint.reject(new CredentialsExpiredError("invalid_grant")); + + // The dead mint belonged to the grant the reconnect replaced; expiring now would retire the + // grant the user just connected. + await expect(adjudicating).resolves.toBe("superseded"); + expect(notify).not.toHaveBeenCalled(); + expect(instance.stored()?.token).toBe("reconnected"); + }); + + it("lets a reconnect racing the heal win when the mint fails for other reasons", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const rejected = instance.identity(); + const mint = Promise.withResolvers(); + + const adjudicating = instance.adjudicateRejection(rejected, { + refresh: () => mint.promise, ...notifyless, + }); + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + mint.reject(new Error("502 from token endpoint")); + + // The rejected identity is demonstrably superseded; unavailable would hand the caller its + // original 401 right after the user reconnected. + await expect(adjudicating).resolves.toBe("superseded"); + expect(instance.stored()?.token).toBe("reconnected"); + }); + + it("collapses concurrent heals of one identity onto one mint", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const rejected = instance.identity(); + const mint = Promise.withResolvers(); + const refresh = vi.fn(() => mint.promise); + + const verdicts = Promise.all([ + instance.adjudicateRejection(rejected, { refresh, ...notifyless }), + instance.adjudicateRejection(rejected, { refresh, ...notifyless }), + ]); + mint.resolve({ token: "minted", expiresAt: live.expiresAt }); + + expect(await verdicts).toEqual(["superseded", "superseded"]); + expect(refresh).toHaveBeenCalledOnce(); + }); + }); + + describe("recorded grant death", () => { + it("refuses every later read once a refresh confirms the grant is dead", async () => { + const kv = makeKv(); + const instance = coordinator(kv); + instance.connect(stale); + const refresh = vi.fn(dead); + + await expect(instance.fresh(refresh)).rejects.toThrow("invalid_grant"); + + // The death outlives the call that found it: rotate, a later read, and a coordinator built + // fresh over the same storage all refuse before reaching the provider. + await expect(instance.rotate(refresh)).rejects.toThrow(CredentialsExpiredError); + await expect(instance.fresh(refresh)).rejects.toThrow(CredentialsExpiredError); + await expect(coordinator(kv).fresh(refresh)).rejects.toThrow(CredentialsExpiredError); + expect(refresh).toHaveBeenCalledOnce(); + // Still stored, so account-owned revoke keeps its material. + expect(instance.stored()).toEqual(stale); + }); + + it("refuses an unexpired grant a rejection verdict already buried", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + const refresh = vi.fn(async () => live); + + expect(await instance.adjudicateRejection(instance.identity(), notifyless)).toBe("expired"); + + // Nothing about `live` looks expired; only the account's own verdict does. + await expect(instance.fresh(refresh)).rejects.toThrow(CredentialsExpiredError); + expect(refresh).not.toHaveBeenCalled(); + + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + expect((await instance.fresh(refresh)).token).toBe("reconnected"); + }); + + it("leaves an ordinary provider failure retryable", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + + await expect(instance.fresh(async () => { throw new Error("502"); })).rejects.toThrow("502"); + expect(await instance.fresh(async () => live)).toEqual(live); + }); + + it("discards a mint that lands after the account recorded the death", async () => { + const discardMint = vi.fn(); + const instance = new CredentialCoordinator( + makeKv(), { expiresAt: creds => creds.expiresAt, discardMint }); + instance.connect(stale); + const mint = Promise.withResolvers(); + + const refreshing = instance.fresh(() => mint.promise); + expect(await instance.adjudicateRejection(instance.identity(), notifyless)).toBe("expired"); + const minted = { token: "too-late", expiresAt: live.expiresAt }; + mint.resolve(minted); + + await expect(refreshing).rejects.toThrow(CredentialsExpiredError); + expect(discardMint).toHaveBeenCalledWith(minted); + expect(instance.stored()).toEqual(stale); + }); + + it("refuses to hand a stale refresh a successor the account already buried", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const mint = Promise.withResolvers(); + + const refreshing = instance.fresh(() => mint.promise); + instance.connect({ token: "second", expiresAt: live.expiresAt }); + expect(await instance.adjudicateRejection(instance.identity(), notifyless)).toBe("expired"); + mint.reject(new CredentialsExpiredError("invalid_grant")); + + // The overtaken refresh resolves against the current grant, which is itself dead. + await expect(refreshing).rejects.toThrow(CredentialsExpiredError); + await expect(instance.fresh(async () => live)).rejects.toThrow(CredentialsExpiredError); + }); + + it("keeps a reconnect landing after a stale death usable", async () => { + const instance = coordinator(makeKv()); + instance.connect(stale); + const mint = Promise.withResolvers(); + + const refreshing = instance.fresh(() => mint.promise); + instance.connect({ token: "reconnected", expiresAt: live.expiresAt }); + mint.reject(new CredentialsExpiredError("invalid_grant")); + + // The death belongs to the identity that died, never to the one that replaced it. + expect((await refreshing).token).toBe("reconnected"); + expect((await instance.fresh(async () => live)).token).toBe("reconnected"); + }); + + it("refuses a stale report rather than promising a successor it has buried", async () => { + const instance = coordinator(makeKv()); + instance.connect(live); + const stranded = instance.identity(); + + instance.connect({ token: "second", expiresAt: live.expiresAt }); + expect(await instance.adjudicateRejection(instance.identity(), notifyless)).toBe("expired"); + + // "superseded" promises a live successor, and there is none: the caller must reconnect + // rather than be told to retry into credentials the account already buried. + expect(await instance.adjudicateRejection(stranded, notifyless)).toBe("expired"); + }); + }); +}); + +describe("CredentialCoordinator over the expiry latch", () => { + const callbackFor = (credentialsExpired: () => Promise) => + ({ credentialsExpired }) as unknown as + NonNullable[1]>; + + it("re-arms the latch at reconnect, so the next confirmed death notifies again", async () => { + const kv = makeKv(); + const credentialsExpired = vi.fn(async () => {}); + const instance = new CredentialCoordinator(kv, { expiresAt: creds => creds.expiresAt }); + const notify = () => notifyCredentialsExpiredOnce(kv, callbackFor(credentialsExpired), "test"); + + instance.connect({ token: "first", expiresAt: Date.now() - 1 }); + await expect(instance.snapshot(dead, { notify })).rejects.toThrow(CredentialsExpiredError); + expect(credentialsExpired).toHaveBeenCalledOnce(); + + // `connect` re-arms the latch itself: a port that had to remember the manual clear would go + // silent on every death after the first, which is exactly what shipped gatekeepers did. + instance.connect({ token: "second", expiresAt: Date.now() - 1 }); + await expect(instance.snapshot(dead, { notify })).rejects.toThrow(CredentialsExpiredError); + expect(credentialsExpired).toHaveBeenCalledTimes(2); + }); + + it("keeps a reconnect that lands mid-notification out of the latch it would silence", async () => { + // The dying grant's notification must not latch the credentials that replaced it, or their + // own death goes unannounced. + const kv = makeKv(); + const notifying = Promise.withResolvers(); + const credentialsExpired = vi.fn(() => notifying.promise); + const instance = new CredentialCoordinator(kv, { expiresAt: creds => creds.expiresAt }); + const notify = () => notifyCredentialsExpiredOnce(kv, callbackFor(credentialsExpired), "test"); + + instance.connect({ token: "first", expiresAt: Date.now() - 1 }); + const dying = instance.snapshot(dead, { notify }); + await vi.waitFor(() => expect(credentialsExpired).toHaveBeenCalled()); + + // A retry cannot revive a grant the account has buried; only a reconnect can. + const revived = { token: "revived", expiresAt: Date.now() - 1 }; + await expect(instance.rotate(async () => revived)).rejects.toThrow(CredentialsExpiredError); + instance.connect(revived); + notifying.resolve(); + await expect(dying).resolves.toMatchObject({ creds: revived }); + + // The reconnected credentials must still announce their own death, which a latch set by the + // notification they raced would swallow. + await expect(instance.snapshot(dead, { notify })).rejects.toThrow(CredentialsExpiredError); + expect(credentialsExpired).toHaveBeenCalledTimes(2); + }); + + it("retries a failed notification from a later read without a second refresh", async () => { + const kv = makeKv(); + let reachable = false; + const credentialsExpired = vi.fn(async () => { + if (!reachable) throw new Error("workshop unreachable"); + }); + const instance = new CredentialCoordinator(kv, { expiresAt: creds => creds.expiresAt }); + const notify = () => notifyCredentialsExpiredOnce(kv, callbackFor(credentialsExpired), "test"); + const refresh = vi.fn(dead); + + instance.connect({ token: "first", expiresAt: Date.now() - 1 }); + const logged = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(instance.snapshot(refresh, { notify })).rejects.toThrow(CredentialsExpiredError); + reachable = true; + await expect(instance.snapshot(refresh, { notify })).rejects.toThrow(CredentialsExpiredError); + } finally { + logged.mockRestore(); + } + + // The recorded death answers the later reads before the provider does, and the notification + // the first one failed to deliver is still owed until it lands. + expect(refresh).toHaveBeenCalledOnce(); + expect(credentialsExpired).toHaveBeenCalledTimes(2); + await expect(instance.snapshot(refresh, { notify })).rejects.toThrow(CredentialsExpiredError); + expect(credentialsExpired).toHaveBeenCalledTimes(2); + }); + + it("keeps a death the account already announced latched across a layout migration", async () => { + const kv = makeKv(); + kv.put("accessToken", "legacy"); + kv.put("expiredNotified", true); + const credentialsExpired = vi.fn(async () => {}); + const instance = coordinator(kv, storage => { + const token = storage.get("accessToken"); + return token === undefined ? undefined : { token, expiresAt: Date.now() - 1 }; + }); + const notify = () => notifyCredentialsExpiredOnce(kv, callbackFor(credentialsExpired), "test"); + + await expect(instance.snapshot(dead, { notify })).rejects.toThrow(CredentialsExpiredError); + // Moving a grant between storage layouts replaces nothing, so re-arming here would announce a + // death the account already reported before it was ported. + expect(credentialsExpired).not.toHaveBeenCalled(); + }); +}); + +describe("credential errors", () => { + /** Mirrors capnweb's error round trip: rebuilt as a plain `Error`, own enumerable props kept. */ + function overCapnweb(error: Error): Error { + const kept = Object.entries(error) + .filter(([key]) => key !== "name" && key !== "message" && key !== "stack"); + return Object.assign(new Error(error.message), Object.fromEntries(kept)); + } + + it("matches a mid-operation replacement by name or transport-surviving code", () => { + expect(isCredentialsChanged(new CredentialsChangedError({ cause: new Error("401") }))) + .toBe(true); + expect(isCredentialsChanged( + Object.assign(new Error("stripped by transport"), { name: "CredentialsChangedError" }))) + .toBe(true); + const wire = overCapnweb(new CredentialsChangedError()); + expect(wire.name).toBe("Error"); + expect(isCredentialsChanged(wire)).toBe(true); + expect(isCredentialsChanged(new Error("some other failure"))).toBe(false); + expect(isCredentialsChanged(new CredentialsExpiredError("expired"))).toBe(false); + expect(isCredentialsChanged("not an error")).toBe(false); + }); + + it("matches confirmed expiry by name or transport-surviving code", () => { + expect(isCredentialsExpired(new CredentialsExpiredError("expired"))).toBe(true); + expect(isCredentialsExpired( + Object.assign(new Error("stripped by transport"), { name: "CredentialsExpiredError" }))) + .toBe(true); + const wire = overCapnweb(new CredentialsExpiredError("expired")); + expect(wire.name).toBe("Error"); + expect(isCredentialsExpired(wire)).toBe(true); + expect(isCredentialsExpired(new Error("some other failure"))).toBe(false); + expect(isCredentialsExpired(new CredentialsChangedError())).toBe(false); + expect(isCredentialsExpired(undefined)).toBe(false); }); }); describe("CredentialSource", () => { - function source(overrides: Partial> = {}) { - const getCredentials = - vi.fn(async () => ({ creds: live, identity: "id-a", generation: "gen-a" })); - const noteCredentialsExpired = vi.fn(async (_identity: string) => {}); + // The one literal in the suite: run() must surface exactly the configured message on expiry, and + // every other assertion matches the error class instead. + const expiredMessage = "Reconnect the account."; + + const fresh: Creds = { token: "fresh", expiresAt: Date.now() + 60 * 60 * 1000 }; + + type SourceOverrides = Partial, "account">> & { + getCredentials?: () => Promise>; + reportCredentialsRejected?: (identity: string) => Promise; + }; + + /** A source over a stub account; unset halves serve one live read and answer expired. */ + function source(overrides: SourceOverrides = {}) { + const { getCredentials: read, reportCredentialsRejected: report, ...options } = overrides; + const getCredentials = vi.fn<() => Promise>>( + read ?? (async () => ({ creds: live, identity: "id-a", generation: "gen-a" }))); + const reportCredentialsRejected = vi.fn<(identity: string) => Promise>( + report ?? (async () => "expired")); const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired }), + account: () => ({ getCredentials, reportCredentialsRejected }), isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", + expiredMessage, + ...options, + }); + return { instance, getCredentials, reportCredentialsRejected }; + } + + /** A source whose account parks every read for the test to release in order. */ + function queuedSource(overrides: SourceOverrides = {}) { + const reads: PromiseWithResolvers>[] = []; + const parked = source({ + getCredentials: () => { + const read = Promise.withResolvers>(); + reads.push(read); + return read.promise; + }, ...overrides, }); - return { instance, getCredentials, noteCredentialsExpired }; + return { reads, ...parked }; + } + + /** + * A source over one mutable account triple: reads serve a copy of `current`, the report answers + * with the given verdict, and `set` — the callback's or the returned one — replaces the triple + * the way a refresh commit or a reconnect does. + */ + function mutableSource( + report: ( + identity: string, + current: CredentialsWithIdentity, + set: (next: CredentialsWithIdentity) => void, + ) => RejectionVerdict, + ) { + let current: CredentialsWithIdentity = + { creds: live, identity: "id-a", generation: "gen-a" }; + const set = (next: CredentialsWithIdentity) => { current = next; }; + return { + ...source({ + getCredentials: async () => ({ ...current }), + reportCredentialsRejected: async identity => report(identity, current, set), + }), + set, + }; + } + + /** + * An account whose adjudication heals: while the rejected identity is current, the report mints + * a successor in place and answers superseded, the way `adjudicateRejection` does for a derived + * bearer. + */ + function healingSource() { + return mutableSource((identity, current, set) => { + if (identity === current.identity) { + set({ creds: fresh, identity: "id-b", generation: current.generation }); + } + return "superseded"; + }); } it("coalesces concurrent account round-trips", async () => { @@ -507,118 +1268,655 @@ describe("CredentialSource", () => { expect(getCredentials).toHaveBeenCalledTimes(2); }); + it("reads the fence alone, coalescing with a concurrent operation's fetch", async () => { + const { instance, getCredentials } = source(); + + const [fence, ran] = await Promise.all([ + instance.read(), + instance.run(async (_creds, read: CredentialRead) => read.identity), + ]); + + // The action-fence capture point outside `run`: the same read, without the credentials. + expect(fence).toEqual({ identity: "id-a", generation: "gen-a" }); + expect(fence).not.toHaveProperty("creds"); + expect(ran).toBe("id-a"); + expect(getCredentials).toHaveBeenCalledOnce(); + }); + it("hands the operation the credentials it fetched", async () => { const { instance } = source(); expect(await instance.run(async creds => creds.token)).toBe("live"); }); - it("surfaces the authority only while the principal is known", async () => { + it("hands the operation the identity and generation of its own read", async () => { + const { instance } = source(); + + const read = await instance.run(async (_creds, attempt) => attempt); + expect(read).toEqual({ identity: "id-a", generation: "gen-a" }); + }); + + it("hands each attempt a fresh read of its own credentials", async () => { + const { instance } = healingSource(); + const handed: CredentialRead[] = []; + const seen: CredentialRead[] = []; + + const result = await instance.run(async (creds, attempt) => { + handed.push(attempt); + seen.push({ ...attempt }); + // A caller may hold or even mutate its read; the source's own state must not ride on it — + // a mangled shared triple would report and fence the wrong identity below. + attempt.identity = "mangled"; + attempt.generation = "mangled"; + if (creds.token === "live") throw new Error("401"); + return creds.token; + }, { replayable: true }); + + // The retry's read names the credentials that attempt actually ran under — the fence an + // action capture must ride, since authority() can move mid-operation. + expect(result).toBe("fresh"); + expect(seen).toEqual([ + { identity: "id-a", generation: "gen-a" }, + { identity: "id-b", generation: "gen-a" }, + ]); + expect(handed[0]).not.toBe(handed[1]); + }); + + it("vouches for a partition only while the principal is known", async () => { let identity = "id-a"; let generation = "gen-a"; - const instance = new CredentialSource({ - account: () => ({ - getCredentials: async () => ({ creds: live, identity, generation }), - noteCredentialsExpired: async () => {}, - }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); - // Nothing fetched yet: a cache keyed on this must bypass, not hit a props-keyed partition. - expect(instance.authority()).toBeUndefined(); + const { instance } = + source({ getCredentials: async () => ({ creds: live, identity, generation }) }); - await instance.get(); - expect(instance.authority()).toBe("gen-a"); + expect(await instance.cacheAuthority()).toBe("gen-a"); - // A reported expiry means a reconnect will rotate the generation; forget the old one. await expect(instance.run(async () => { throw new Error("401"); })) - .rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBeUndefined(); + .rejects.toThrow(CredentialsExpiredError); // The account keeps the dead grant until reconnect: refetching the same identity must not // restore its partition, or hit-only cache paths would mask the outage for the TTL. - await instance.get(); - expect(instance.authority()).toBeUndefined(); + expect(await instance.cacheAuthority()).toBeUndefined(); // A fetch adopting a different identity — refresh or reconnect — re-establishes it. identity = "id-b"; generation = "gen-b"; - await instance.get(); - expect(instance.authority()).toBe("gen-b"); + expect(await instance.cacheAuthority()).toBe("gen-b"); }); - it("reports expiry against the identity the failed call used", async () => { - const { instance, getCredentials, noteCredentialsExpired } = source(); + it("reports the rejection against the identity the failed call used", async () => { + const { instance, getCredentials, reportCredentialsRejected } = source(); - await expect(instance.run(async () => { throw new Error("401"); })) - .rejects.toThrow("Reconnect the account."); - expect(noteCredentialsExpired).toHaveBeenCalledWith("id-a"); + const failure = instance.run(async () => { throw new Error("401"); }); + await expect(failure).rejects.toThrow(CredentialsExpiredError); + // The one message pin: expiry surfaces exactly the configured display-safe wording. + await expect(failure).rejects.toThrow(expiredMessage); + expect(reportCredentialsRejected).toHaveBeenCalledWith("id-a"); await instance.get(); expect(getCredentials).toHaveBeenCalledTimes(2); }); - it("treats an auth failure under superseded credentials as stale, not expiry", async () => { + it("keeps a successor adopted while the rejection answer was in flight", async () => { + // The ask and the fetch ride separate account stubs, so nothing orders their replies: an + // honest "expired" for the old identity can land after a reconnect was already adopted. let identity = "id-a"; let generation = "gen-a"; - const noteCredentialsExpired = vi.fn(async (_identity: string) => {}); - const instance = new CredentialSource({ - account: () => ({ - getCredentials: async () => ({ creds: live, identity, generation }), - noteCredentialsExpired, - }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", + const asked = Promise.withResolvers(); + const answering = Promise.withResolvers(); + const { instance } = source({ + getCredentials: async () => ({ creds: live, identity, generation }), + reportCredentialsRejected: async () => { + asked.resolve(); + await answering.promise; + return "expired"; + }, }); - await expect(instance.run(async () => { - // A reconnect lands and another caller refetches while this call is in flight. - identity = "id-b"; - generation = "gen-b"; - await instance.get(); - throw new Error("401"); - })).rejects.toThrow("credentials changed during the operation"); + const failure = instance.run(async () => { throw new Error("401") }, { replayable: true }); + await asked.promise; + + identity = "id-b"; + generation = "gen-b"; + await instance.get(); + answering.resolve(); + + // Stale, not terminal: the reconnect the user just completed must not be reported dead. + await expect(failure).rejects.toThrow(CredentialsChangedError); + }); + + it("retries a replayable operation once after the account heals past the rejection", async () => { + const { instance, getCredentials, reportCredentialsRejected } = healingSource(); - // Reporting would expire the grant the user just reconnected, and clearing the authority - // would drop its live partition; both belong to the grant that actually died. - expect(noteCredentialsExpired).not.toHaveBeenCalled(); - expect(instance.authority()).toBe("gen-b"); + const result = await instance.run(async creds => { + if (creds.token === "live") throw new Error("401"); + return creds.token; + }, { replayable: true }); + + expect(result).toBe("fresh"); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + expect(reportCredentialsRejected).toHaveBeenCalledWith("id-a"); + // The retry reads fresh — the verdict's fence bump forgot the pre-ask flight — and the + // single-threaded account answers it after the heal's commit. + expect(getCredentials).toHaveBeenCalledTimes(2); }); - it("keeps the reconnect message when reporting expiry fails", async () => { + it("reports the identity the retry actually used when its credentials are rejected too", async () => { + const { instance, reportCredentialsRejected } = mutableSource((identity, _current, set) => { + if (identity !== "id-a") return "expired"; + set({ creds: fresh, identity: "id-b", generation: "gen-a" }); + return "superseded"; + }); + const operation = vi.fn(async () => { throw new Error("401"); }); + + await expect(instance.run(operation, { replayable: true })) + .rejects.toThrow(CredentialsExpiredError); + + // The second ask names what the retry ran under: naming the first grant instead would be + // gated out as moved-past, leaving the dead successor reading as retryable forever. + expect(operation).toHaveBeenCalledTimes(2); + expect(reportCredentialsRejected).toHaveBeenNthCalledWith(1, "id-a"); + expect(reportCredentialsRejected).toHaveBeenNthCalledWith(2, "id-b"); + }); + + it("resolves a superseded verdict on a non-replayable operation into a retryable error", async () => { + // Another consumer over the same account healed past id-a; this snapshot cannot see that, so + // the account's answer is what keeps the caller off a false reconnect prompt — and without + // `replayable`, off a second execution the operation cannot afford. + const operation = vi.fn(async () => { throw new Error("401"); }); + const { instance, reportCredentialsRejected } = + source({ reportCredentialsRejected: async () => "superseded" }); + + await expect(instance.run(operation)).rejects.toThrow(CredentialsChangedError); + expect(operation).toHaveBeenCalledOnce(); + expect(reportCredentialsRejected).toHaveBeenCalledWith("id-a"); + }); + + it("surfaces the original rejection when the account cannot adjudicate", async () => { + const rejection = new Error("401"); + const operation = vi.fn(async () => { throw rejection; }); + const { instance } = source({ + reportCredentialsRejected: async () => "unavailable", + isAuthError: error => error === rejection, + }); + + // Nothing was adjudicated — replayable or not, the caller gets the provider error it actually + // saw, and the heal's own failure lives in the account's logs. + await expect(instance.run(operation, { replayable: true })).rejects.toBe(rejection); + await expect(instance.run(operation)).rejects.toBe(rejection); + expect(operation).toHaveBeenCalledTimes(2); + + // No verdict landed: the identity was never marked dead, so the next fetch re-adopts it — + // only the round-trip window bypassed the cache. + expect(await instance.cacheAuthority()).toBe("gen-a"); + }); + + it("surfaces the provider error when the verdict is malformed", async () => { + // The RPC boundary can hand back anything, and an unrecognized answer adjudicates nothing: + // synthesizing an expiry would retire a possibly-live account over a transport bug. + const rejection = new Error("401"); + const { instance } = source({ + reportCredentialsRejected: async () => "definitely" as unknown as RejectionVerdict, + }); + + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(instance.run(async () => { throw rejection }, { replayable: true })) + .rejects.toBe(rejection); + expect(logged).toHaveBeenCalledOnce(); + } finally { + logged.mockRestore(); + } + + // Never adjudicated: the identity is not dead-marked, so the next read re-adopts. + expect(await instance.cacheAuthority()).toBe("gen-a"); + }); + + it("surfaces the provider error when the report cannot reach the account", async () => { const logged = vi.spyOn(console, "error").mockImplementation(() => {}); try { + const rejection = new Error("401"); const { instance } = source({ - account: () => ({ - getCredentials: async () => ({ creds: live, identity: "id-a", generation: "gen-a" }), - noteCredentialsExpired: async () => { throw new Error("account unreachable"); }, - }), + reportCredentialsRejected: async () => { throw new Error("account unreachable") }, }); - await expect(instance.run(async () => { throw new Error("401"); })) - .rejects.toThrow("Reconnect the account."); + // An outage adjudicates nothing, so the caller sees the rejection it actually got rather + // than an expiry no account confirmed. + await expect(instance.run(async () => { throw rejection })).rejects.toBe(rejection); expect(logged).toHaveBeenCalledOnce(); + + // A transient outage is not the account's word: the identity is not dead-marked, so the + // next read re-adopts and caching survives the activation. + expect(await instance.cacheAuthority()).toBe("gen-a"); } finally { logged.mockRestore(); } }); + it("refuses a read served under the reserved empty identity, dropping the authority it had", async () => { + let identity = "id-a"; + const { instance } = source({ + getCredentials: async () => ({ creds: live, identity, generation: "gen-a" }), + }); + + expect(await instance.cacheAuthority()).toBe("gen-a"); + + // An account that cannot fence this read cannot vouch for the partition it stamped earlier. + identity = ""; + await expect(instance.get()).rejects.toThrow('reserved "" identity'); + await expect(instance.cacheAuthority()).rejects.toThrow('reserved "" identity'); + }); + + it("refuses the retry when the refetch crosses a reconnect", async () => { + const operation = vi.fn(async () => { throw new Error("401"); }); + const { instance, reportCredentialsRejected } = mutableSource((_identity, _current, set) => { + // A reconnect lands while the report is in flight; the account's gate answers superseded. + set({ creds: fresh, identity: "id-b", generation: "gen-b" }); + return "superseded"; + }); + + await expect(instance.run(operation, { replayable: true })) + .rejects.toThrow(CredentialsChangedError); + + // The replacement belongs to a connection the caller never fetched: running under it could + // act as a different principal, so the caller re-enters and fetches it deliberately. The + // refetch itself adopted the live reconnect, so its authority stands. + expect(operation).toHaveBeenCalledOnce(); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it("refuses the retry when the refetch re-serves the rejected identity", async () => { + // A hand-written account whose "heal" lazily re-serves the very credentials the provider + // rejected: superseded promised a successor, so the same identity back means re-entering, + // never burning the one retry on a corpse and then falsely retiring the grant. + const operation = vi.fn(async () => { throw new Error("401"); }); + const { instance, reportCredentialsRejected } = + source({ reportCredentialsRejected: async () => "superseded" }); + + await expect(instance.run(operation, { replayable: true })) + .rejects.toThrow(CredentialsChangedError); + expect(operation).toHaveBeenCalledOnce(); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it("refuses a retry whose fenced-out refetch an adopted reconnect postdates", async () => { + // Two runs read one grant and both 401. The first's post-verdict refetch dangles; the second's + // verdict fences it out, and the second's own refetch adopts a reconnect. The dangling + // response — same generation as the rejected read — must never be executed under. + const { instance, reads } = + queuedSource({ reportCredentialsRejected: async () => "superseded" }); + + const gate = Promise.withResolvers(); + const first = vi.fn(async () => { throw new Error("401"); }); + const second = vi.fn(async () => { await gate.promise; throw new Error("401"); }); + const runFirst = instance.run(first, { replayable: true }); + const runSecond = instance.run(second, { replayable: true }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + + // The first run's verdict lands and its refetch dangles before the second run even fails. + await vi.waitFor(() => expect(reads).toHaveLength(2)); + gate.resolve(); + await vi.waitFor(() => expect(reads).toHaveLength(3)); + + // The second run's refetch adopts a reconnect; the dangling one answers under the old + // generation. Both re-enter — neither runs under a read the source no longer stands behind. + reads[2].resolve({ creds: fresh, identity: "id-c", generation: "gen-c" }); + await expect(runSecond).rejects.toThrow(CredentialsChangedError); + reads[1].resolve({ creds: fresh, identity: "id-b", generation: "gen-a" }); + await expect(runFirst).rejects.toThrow(CredentialsChangedError); + + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + }); + + it("keeps a reconnect's authority when a fenced-out refetch re-serves the rejected identity", async () => { + // Same shape as above, but the dangling response re-serves the rejected identity itself. The + // re-serve branch undoes a refetch's adoption — this refetch adopted nothing, so acting on it + // would destroy the reconnect's live authority instead. + const { instance, reads } = + queuedSource({ reportCredentialsRejected: async () => "superseded" }); + + const gate = Promise.withResolvers(); + const runFirst = instance.run(async () => { throw new Error("401"); }, { replayable: true }); + const runSecond = instance.run(async () => { + await gate.promise; + throw new Error("401"); + }, { replayable: true }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + + await vi.waitFor(() => expect(reads).toHaveLength(2)); + gate.resolve(); + await vi.waitFor(() => expect(reads).toHaveLength(3)); + + reads[2].resolve({ creds: fresh, identity: "id-c", generation: "gen-c" }); + await expect(runSecond).rejects.toThrow(CredentialsChangedError); + reads[1].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + await expect(runFirst).rejects.toThrow(CredentialsChangedError); + + }); + + it("never runs the retry under a successor already adjudicated dead", async () => { + const gate = Promise.withResolvers(); + const { instance } = mutableSource((identity, _current, set) => { + if (identity === "id-b") return "expired"; + set({ creds: fresh, identity: "id-b", generation: "gen-a" }); + return "superseded"; + }); + + // Two calls share one read; the slow one's rejection lands after the healed successor was + // itself rejected and adjudicated dead. + const slowOp = vi.fn(async () => { + await gate.promise; + throw new Error("401"); + }); + const fast = instance.run(async () => { throw new Error("401"); }, { replayable: true }); + const slow = instance.run(slowOp, { replayable: true }); + await expect(fast).rejects.toThrow(CredentialsExpiredError); + + gate.resolve(); + await expect(slow).rejects.toThrow(CredentialsExpiredError); + // The slow retry's refetch returned the dead grant the account still serves: no provider call + // runs under credentials the source confirmed dead. + expect(slowOp).toHaveBeenCalledOnce(); + }); + + it("re-enters instead of expiring when a dead successor's fenced-out refetch postdates a reconnect", async () => { + const { instance, reads } = queuedSource({ + reportCredentialsRejected: async identity => + identity === "id-b" ? "expired" : "superseded", + }); + + const gate = Promise.withResolvers(); + const first = vi.fn(async () => { throw new Error("401"); }); + const runFirst = instance.run(first, { replayable: true }); + const runSecond = instance.run(async () => { + await gate.promise; + throw new Error("401"); + }, { replayable: true }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + + // The first run's refetch dangles; the second's verdict fences it out, and the second's own + // refetch adopts the healed successor — whose repeat rejection is then adjudicated dead. + await vi.waitFor(() => expect(reads).toHaveLength(2)); + gate.resolve(); + await vi.waitFor(() => expect(reads).toHaveLength(3)); + reads[2].resolve({ creds: fresh, identity: "id-b", generation: "gen-a" }); + await expect(runSecond).rejects.toThrow(CredentialsExpiredError); + + // A reconnect is adopted before the dangling refetch answers with the dead successor. + const revived = instance.get(); + await vi.waitFor(() => expect(reads).toHaveLength(4)); + reads[3].resolve({ creds: fresh, identity: "id-c", generation: "gen-c" }); + await revived; + reads[1].resolve({ creds: fresh, identity: "id-b", generation: "gen-a" }); + + // Stale evidence about a read the source no longer stands behind: re-enter, don't tell the + // caller a freshly reconnected account is expired. + await expect(runFirst).rejects.toThrow(CredentialsChangedError); + expect(first).toHaveBeenCalledOnce(); + }); + + it("makes at most two attempts however many verdicts answer superseded", async () => { + let minted = 0; + const operation = vi.fn(async () => { throw new Error("401"); }); + const { instance, reportCredentialsRejected } = mutableSource((_identity, _current, set) => { + minted += 1; + set({ creds: fresh, identity: `id-${minted}`, generation: "gen-a" }); + return "superseded"; + }); + + await expect(instance.run(operation, { replayable: true })) + .rejects.toThrow(CredentialsChangedError); + + // The account would heal forever; the source stops at two executions and hands the caller + // the retryable error instead of looping. + expect(operation).toHaveBeenCalledTimes(2); + expect(reportCredentialsRejected).toHaveBeenCalledTimes(2); + }); + + it("coalesces a burst of rejections onto one ask and one refetch", async () => { + const gate = Promise.withResolvers(); + const { instance, getCredentials, reportCredentialsRejected } = healingSource(); + const operation = async (creds: Creds) => { + if (creds.token === "live") { + await gate.promise; + throw new Error("401"); + } + return creds.token; + }; + + // Two provider calls 401 together, the way one dead bearer fails parallel calls in a request. + const calls = [ + instance.run(operation, { replayable: true }), + instance.run(operation, { replayable: true }), + ]; + gate.resolve(); + + expect(await Promise.all(calls)).toEqual(["fresh", "fresh"]); + // One shared read, one shared ask — the account's fence-keyed heal collapses behind it — and + // one shared refetch serving both retries. + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + expect(getCredentials).toHaveBeenCalledTimes(2); + }); + + it("replays under a successor a sibling's heal already adopted, spending no ask", async () => { + const gate = Promise.withResolvers(); + const { instance, reportCredentialsRejected } = healingSource(); + const operation = async (creds: Creds) => { + if (creds.token !== "live") return creds.token; + await gate.promise; + throw new Error("401"); + }; + + const slow = instance.run(operation, { replayable: true }); + // A sibling call heals and adopts the successor before the slow call's 401 lands. + expect(await instance.run(async creds => { + if (creds.token === "live") throw new Error("401"); + return creds.token; + }, { replayable: true })).toBe("fresh"); + + // The stale failure resolves by replay, not by a re-entry the heal was meant to hide — and + // the adopted successor already answers the ask the moved-past gate would. + gate.resolve(); + expect(await slow).toBe("fresh"); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it("re-enters instead of replaying when the adopted successor reaches a non-replayable call", async () => { + const gate = Promise.withResolvers(); + const { instance, reportCredentialsRejected } = healingSource(); + const operation = vi.fn(async (creds: Creds) => { + if (creds.token !== "live") return creds.token; + await gate.promise; + throw new Error("401"); + }); + + const slow = instance.run(operation); + // A sibling call heals and adopts the successor before the slow call's 401 lands. + expect(await instance.run(async creds => { + if (creds.token === "live") throw new Error("401"); + return creds.token; + }, { replayable: true })).toBe("fresh"); + + // The successor answers what an ask would, but a second execution is not this caller's to + // spend: the operation runs once and the caller re-enters. + gate.resolve(); + await expect(slow).rejects.toThrow(CredentialsChangedError); + expect(operation).toHaveBeenCalledOnce(); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it("adjudicates a repeat report afresh instead of caching the verdict", async () => { + const { instance, reportCredentialsRejected, set } = mutableSource((identity, current) => + identity === current.identity ? "expired" : "superseded"); + + await expect(instance.run(async () => { throw new Error("401"); }, { replayable: true })) + .rejects.toThrow(CredentialsExpiredError); + + // A straggler reads the dead grant the account still serves, then the user reconnects. + const stalled = await stalledRun(instance, { replayable: true }); + set({ creds: fresh, identity: "id-b", generation: "gen-b" }); + stalled.release(); + + // The account re-adjudicates and answers moved-past; a cached verdict would expire the + // reconnect the caller only needs to re-enter into. + await expect(stalled.run).rejects.toThrow(CredentialsChangedError); + expect(reportCredentialsRejected).toHaveBeenCalledTimes(2); + expect(await instance.run(async creds => creds.token)).toBe("fresh"); + }); + + it("skips the ask when a reconnect was adopted before the rejection resolved", async () => { + const reads = [ + { creds: live, identity: "id-a", generation: "gen-a" }, + { creds: fresh, identity: "id-b", generation: "gen-b" }, + ]; + const { instance, reportCredentialsRejected } = + source({ getCredentials: async () => reads.shift()! }); + + const stalled = await stalledRun(instance, { replayable: true }); + + // A plain read adopts a reconnect while the operation is still in flight. + expect(await instance.get()).toEqual(fresh); + + // The outcome is already decided: no ask is spent on the superseded read, and a heal or its + // failure cannot reach a caller who only needs to re-enter. + stalled.release(); + await expect(stalled.run).rejects.toThrow(CredentialsChangedError); + expect(reportCredentialsRejected).not.toHaveBeenCalled(); + }); + + it("skips the second ask when a reconnect is adopted during the retry", async () => { + const replaying = Promise.withResolvers(); + const replayGate = Promise.withResolvers(); + const { instance, reportCredentialsRejected, set } = + mutableSource((_identity, _current, mint) => { + mint({ creds: fresh, identity: "id-b", generation: "gen-a" }); + return "superseded"; + }); + + const call = instance.run(async creds => { + if (creds.token === "live") throw new Error("401"); + replaying.resolve(); + await replayGate.promise; + throw new Error("401"); + }, { replayable: true }); + await replaying.promise; + + // A reconnect lands and a plain read adopts it while the retry is out. + set({ creds: live, identity: "id-c", generation: "gen-c" }); + expect(await instance.get()).toEqual(live); + + // The retry ran under credentials the reconnect superseded: the only verdict the account + // could return is already known, so the caller re-enters without a second ask and the live + // authority stands. + replayGate.resolve(); + await expect(call).rejects.toThrow(CredentialsChangedError); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it("passes a retry failure that is not a credential rejection through untouched", async () => { + const { instance, reportCredentialsRejected } = healingSource(); + + await expect(instance.run(async creds => { + throw new Error(creds.token === "live" ? "401" : "500"); + }, { replayable: true })).rejects.toThrow("500"); + expect(reportCredentialsRejected).toHaveBeenCalledOnce(); + }); + + it.each([ + { verdict: "expired", error: CredentialsExpiredError, readopted: undefined }, + { verdict: "superseded", error: CredentialsChangedError, readopted: "gen-a" }, + ] as const)("never re-adopts the rejected identity while its verdict is pending ($verdict)", + async ({ verdict, error, readopted }) => { + const answer = Promise.withResolvers(); + const { instance, reportCredentialsRejected } = + source({ reportCredentialsRejected: () => answer.promise }); + + const report = instance.run(async () => { throw new Error("401"); }); + await vi.waitFor(() => expect(reportCredentialsRejected).toHaveBeenCalled()); + + // A read landing mid-adjudication is served but never adopted: the rejected partition must + // not come back to cache-first readers while the verdict is out. + expect(await instance.get()).toEqual(live); + expect(await instance.cacheAuthority()).toBeUndefined(); + + answer.resolve(verdict); + await expect(report).rejects.toThrow(error); + + // The bypass is the round trip, not the identity: once superseded settles, a fresh read + // adopts again, while a confirmed-dead identity stays refused. + expect(await instance.cacheAuthority()).toBe(readopted); + }); + + it("stops vouching for a rejected authority while the verdict is pending", async () => { + const answer = Promise.withResolvers(); + const { instance, reportCredentialsRejected } = + source({ reportCredentialsRejected: () => answer.promise }); + + expect(await instance.cacheAuthority()).toBe("gen-a"); + + // The rejection alone drops the authority: cache-first readers bypass during the round trip + // rather than serving the partition the provider just rejected. + const report = instance.run(async () => { throw new Error("401"); }); + await vi.waitFor(() => expect(reportCredentialsRejected).toHaveBeenCalled()); + expect(await instance.cacheAuthority()).toBeUndefined(); + + answer.resolve("expired"); + await expect(report).rejects.toThrow(CredentialsExpiredError); + }); + + it("reports a rejection served by a fenced read once the authority is unknown", async () => { + const answer = Promise.withResolvers(); + const { instance, reads, reportCredentialsRejected } = queuedSource({ + reportCredentialsRejected: async identity => + identity === "id-a" ? answer.promise : "expired", + }); + + const first = instance.run(async () => { throw new Error("401"); }); + await vi.waitFor(() => expect(reads).toHaveLength(1)); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + await vi.waitFor(() => expect(reportCredentialsRejected).toHaveBeenCalledWith("id-a")); + + // A second run's read starts before the verdict lands, so its result arrives fenced: served + // to the caller, never adopted. + const second = instance.run(async () => { throw new Error("401"); }); + await vi.waitFor(() => expect(reads).toHaveLength(2)); + answer.resolve("superseded"); + await expect(first).rejects.toThrow(CredentialsChangedError); + reads[1].resolve({ creds: fresh, identity: "id-b", generation: "gen-b" }); + + // The retained id-a identity is no successor once its authority dropped — the failure under + // the account's actual current credential must reach the account, not resolve as stale. + await expect(second).rejects.toThrow(CredentialsExpiredError); + expect(reportCredentialsRejected).toHaveBeenLastCalledWith("id-b"); + }); + + it("treats an auth failure under superseded credentials as stale, not expiry", async () => { + let identity = "id-a"; + let generation = "gen-a"; + const { instance, reportCredentialsRejected } = + source({ getCredentials: async () => ({ creds: live, identity, generation }) }); + + await expect(instance.run(async () => { + // A reconnect lands and another caller refetches while this call is in flight. + identity = "id-b"; + generation = "gen-b"; + await instance.get(); + throw new Error("401"); + })).rejects.toThrow(CredentialsChangedError); + + // Reporting would expire the grant the user just reconnected; the report belongs to the + // grant that actually died. + expect(reportCredentialsRejected).not.toHaveBeenCalled(); + }); + it("passes other failures through untouched", async () => { - const { instance, noteCredentialsExpired } = source(); + const { instance, reportCredentialsRejected } = source(); await expect(instance.run(async () => { throw new Error("500"); })).rejects.toThrow("500"); - expect(noteCredentialsExpired).not.toHaveBeenCalled(); + expect(reportCredentialsRejected).not.toHaveBeenCalled(); }); it("never hands a caller the fetch in flight when credentials were reported dead", async () => { - const fetches: Array<(fetched: CredentialsWithIdentity) => void> = []; - const getCredentials = vi.fn(() => new Promise>(resolve => { - fetches.push(resolve); - })); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired: async () => {} }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads, getCredentials } = queuedSource(); // Two provider calls share one fetch, the way parallel calls in one gadget request do. const first = Promise.withResolvers(); @@ -631,17 +1929,17 @@ describe("CredentialSource", () => { await second.promise; throw new Error("401"); }); - fetches[0]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); // The first 401 empties the cache, so the next caller opens a second fetch... second.resolve(); - await expect(secondCall).rejects.toThrow("Reconnect the account."); + await expect(secondCall).rejects.toThrow(CredentialsExpiredError); const riding = instance.get(); expect(getCredentials).toHaveBeenCalledTimes(2); // ...which is still in flight when the second 401 declares those credentials dead. first.resolve(); - await expect(firstCall).rejects.toThrow("Reconnect the account."); + await expect(firstCall).rejects.toThrow(CredentialsExpiredError); // Riding it would hand a caller credentials that have already been reported expired. const after = instance.get(); @@ -649,29 +1947,21 @@ describe("CredentialSource", () => { const fromSecond = { token: "second-fetch", expiresAt: live.expiresAt }; const fromThird = { token: "third-fetch", expiresAt: live.expiresAt }; - fetches[1]?.({ creds: fromSecond, identity: "id-b", generation: "gen-a" }); - fetches[2]?.({ creds: fromThird, identity: "id-c", generation: "gen-a" }); + reads[1].resolve({ creds: fromSecond, identity: "id-b", generation: "gen-a" }); + reads[2].resolve({ creds: fromThird, identity: "id-c", generation: "gen-a" }); expect(await riding).toEqual(fromSecond); expect(await after).toEqual(fromThird); }); it("never resurrects a generation cleared while another fetch was in flight", async () => { - const fetches: Array<(fetched: CredentialsWithIdentity) => void> = []; - const getCredentials = vi.fn(() => new Promise>(resolve => { - fetches.push(resolve); - })); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired: async () => {} }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads, getCredentials } = queuedSource(); const gate = Promise.withResolvers(); const call = instance.run(async () => { await gate.promise; throw new Error("401"); }); - fetches[0]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await instance.get()).toEqual(live); // Another caller's fetch opens while the provider call is out, and is still in flight when the @@ -679,200 +1969,347 @@ describe("CredentialSource", () => { const pending = instance.get(); expect(getCredentials).toHaveBeenCalledTimes(2); gate.resolve(); - await expect(call).rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBeUndefined(); + await expect(call).rejects.toThrow(CredentialsExpiredError); // That fetch resolving carries the dead grant's generation; adopting it would put the cache // back on the dead partition. - fetches[1]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[1].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await pending).toEqual(live); - expect(instance.authority()).toBeUndefined(); // A fetch opened after the clear re-establishes the principal. const after = instance.get(); - fetches[2]?.({ creds: live, identity: "id-b", generation: "gen-b" }); + reads[2].resolve({ creds: live, identity: "id-b", generation: "gen-b" }); expect(await after).toEqual(live); - expect(instance.authority()).toBe("gen-b"); - }); - - it("drops the authority only when a fetch fails with confirmed expiry", async () => { - let failure: Error | undefined; - const instance = new CredentialSource({ - account: () => ({ - getCredentials: async () => { - if (failure) throw failure; - return { creds: live, identity: "id-a", generation: "gen-a" }; - }, - noteCredentialsExpired: async () => {}, - }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); - - await instance.get(); - expect(instance.authority()).toBe("gen-a"); - - // An account hiccup is not an expiry: the partition survives and warm reads keep hitting. - failure = new Error("account unreachable"); - await expect(instance.get()).rejects.toThrow("account unreachable"); - expect(instance.authority()).toBe("gen-a"); - - // A failed refresh is a confirmed expiry. RPC strips the class, so the name is the contract. - failure = Object.assign(new Error("Reconnect the account."), - { name: "CredentialsExpiredError" }); - await expect(instance.get()).rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBeUndefined(); }); it("ignores a straggler fetch that rejects with expiry after the partition revived", async () => { - const fetches: Array>> = []; - const getCredentials = vi.fn(() => { - const fetch = Promise.withResolvers>(); - fetches.push(fetch); - return fetch.promise; - }); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired: async () => {} }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads } = queuedSource(); // Grant A is adopted, another fetch opens, then A's expiry forgets that fetch mid-flight. const gate = Promise.withResolvers(); const call = instance.run(async () => { await gate.promise; throw new Error("401"); }); - fetches[0]?.resolve({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await instance.get()).toEqual(live); const straggler = instance.get(); gate.resolve(); - await expect(call).rejects.toThrow("Reconnect the account."); + await expect(call).rejects.toThrow(CredentialsExpiredError); // A successful refresh commits a new identity on the same connection: the partition revives. const revived = instance.get(); - fetches[2]?.resolve({ creds: live, identity: "id-b", generation: "gen-a" }); + reads[2].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); expect(await revived).toEqual(live); - expect(instance.authority()).toBe("gen-a"); // The forgotten fetch's stale coalesced refresh finally fails; it must not clear the revival. - fetches[1]?.reject( - Object.assign(new Error("Reconnect the account."), { name: "CredentialsExpiredError" })); - await expect(straggler).rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBe("gen-a"); + reads[1].reject( + Object.assign(new Error("grant expired upstream"), { name: "CredentialsExpiredError" })); + await expect(straggler).rejects.toThrow("grant expired upstream"); + const authority = instance.cacheAuthority(); + reads[3].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); + expect(await authority).toBe("gen-a"); }); it("never adopts a straggler fetch that outlived later expiry reports", async () => { - const fetches: Array<(fetched: CredentialsWithIdentity) => void> = []; - const getCredentials = vi.fn(() => new Promise>(resolve => { - fetches.push(resolve); - })); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired: async () => {} }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads, getCredentials } = queuedSource(); // Grant A is adopted, another fetch opens, then A's expiry forgets that fetch mid-flight. const gate = Promise.withResolvers(); const callA = instance.run(async () => { await gate.promise; throw new Error("401"); }); - fetches[0]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await instance.get()).toEqual(live); const straggler = instance.get(); expect(getCredentials).toHaveBeenCalledTimes(2); gate.resolve(); - await expect(callA).rejects.toThrow("Reconnect the account."); + await expect(callA).rejects.toThrow(CredentialsExpiredError); // Grant B is adopted and dies too, rotating the dead marker away from A. const callB = instance.run(async () => { throw new Error("401"); }); - fetches[2]?.({ creds: live, identity: "id-b", generation: "gen-b" }); - await expect(callB).rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBeUndefined(); + reads[2].resolve({ creds: live, identity: "id-b", generation: "gen-b" }); + await expect(callB).rejects.toThrow(CredentialsExpiredError); // The straggler resolves with A, which no longer matches the marker. Adopting it would // resurrect a dead partition and misroute genuine B failures as superseded. - fetches[1]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[1].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await straggler).toEqual(live); - expect(instance.authority()).toBeUndefined(); // A failure under the still-current dead grant routes to expiry, not "retry". const callC = instance.run(async () => { throw new Error("401"); }); - fetches[3]?.({ creds: live, identity: "id-b", generation: "gen-b" }); - await expect(callC).rejects.toThrow("Reconnect the account."); - expect(instance.authority()).toBeUndefined(); + reads[3].resolve({ creds: live, identity: "id-b", generation: "gen-b" }); + await expect(callC).rejects.toThrow(CredentialsExpiredError); }); it("reports a failure under fenced-out credentials as expiry when nothing live succeeded them", async () => { - const fetches: Array<(fetched: CredentialsWithIdentity) => void> = []; - const getCredentials = vi.fn(() => new Promise>(resolve => { - fetches.push(resolve); - })); - const noteCredentialsExpired = vi.fn(async (_identity: string) => {}); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads, getCredentials, reportCredentialsRejected } = queuedSource(); // Grant A is adopted, a concurrent operation's fetch opens, then A's expiry fences it out. const gate = Promise.withResolvers(); const callA = instance.run(async () => { await gate.promise; throw new Error("401"); }); - fetches[0]?.({ creds: live, identity: "id-a", generation: "gen-a" }); + reads[0].resolve({ creds: live, identity: "id-a", generation: "gen-a" }); expect(await instance.get()).toEqual(live); const callB = instance.run(async () => { throw new Error("401"); }); expect(getCredentials).toHaveBeenCalledTimes(2); gate.resolve(); - await expect(callA).rejects.toThrow("Reconnect the account."); + await expect(callA).rejects.toThrow(CredentialsExpiredError); // The fenced-out fetch delivers B, which fails too. Nothing live was adopted since A's // report, so "the credentials changed" would be a lie — B's death is fresh evidence. - fetches[1]?.({ creds: live, identity: "id-b", generation: "gen-a" }); - await expect(callB).rejects.toThrow("Reconnect the account."); - expect(noteCredentialsExpired).toHaveBeenCalledWith("id-b"); - expect(instance.authority()).toBeUndefined(); + reads[1].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); + await expect(callB).rejects.toThrow(CredentialsExpiredError); + expect(reportCredentialsRejected).toHaveBeenCalledWith("id-b"); // The account keeps serving the unrefreshed grant; readopting it would let cache hits mask // the expiry it just confirmed. const refetch = instance.get(); - fetches[2]?.({ creds: live, identity: "id-b", generation: "gen-a" }); + reads[2].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); expect(await refetch).toEqual(live); - expect(instance.authority()).toBeUndefined(); }); it("keeps a dead grant refused however many stale failures report after it", async () => { - const fetches: Array<(fetched: CredentialsWithIdentity) => void> = []; - const getCredentials = vi.fn(() => new Promise>(resolve => { - fetches.push(resolve); - })); - const instance = new CredentialSource({ - account: () => ({ getCredentials, noteCredentialsExpired: async () => {} }), - isAuthError: error => error instanceof Error && error.message === "401", - expiredMessage: "Reconnect the account.", - }); + const { instance, reads } = queuedSource(); // Nine operations park holding distinct stale identities, read one at a time so nothing // coalesces. - const gates = Array.from({ length: 9 }, () => Promise.withResolvers()); - const stale: Promise[] = []; - for (const [index, gate] of gates.entries()) { - const reading = Promise.withResolvers(); - stale.push(instance.run(async () => { - reading.resolve(); - await gate.promise; - throw new Error("401"); - })); - fetches[index]?.({ creds: live, identity: `id-stale-${index}`, generation: "gen-a" }); - await reading.promise; + const stale: Array<{ run: Promise; release: () => void }> = []; + for (let index = 0; index < 9; index += 1) { + const stalling = stalledRun(instance); + reads[index].resolve({ creds: live, identity: `id-stale-${index}`, generation: "gen-a" }); + stale.push(await stalling); } - // Grant B is adopted and dies, then every stale operation reports its own identity dead. + // Grant B — a same-generation rotation, so no adopted successor proves the stale reads + // superseded — is adopted and dies, then every stale operation reports its own identity dead. const callB = instance.run(async () => { throw new Error("401"); }); - fetches[9]?.({ creds: live, identity: "id-b", generation: "gen-b" }); - await expect(callB).rejects.toThrow("Reconnect the account."); - for (const gate of gates) gate.resolve(); - for (const failure of stale) await expect(failure).rejects.toThrow("Reconnect the account."); + reads[9].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); + await expect(callB).rejects.toThrow(CredentialsExpiredError); + for (const { release } of stale) release(); + for (const { run } of stale) await expect(run).rejects.toThrow(CredentialsExpiredError); // The stale reports land after B's in mark order; none may push B back into adoption. const refetch = instance.get(); - fetches[10]?.({ creds: live, identity: "id-b", generation: "gen-b" }); + reads[10].resolve({ creds: live, identity: "id-b", generation: "gen-a" }); expect(await refetch).toEqual(live); - expect(instance.authority()).toBeUndefined(); + }); +}); + +describe("CredentialSource over a CredentialCoordinator", () => { + // The two halves composed the way a port wires them: `getCredentials` serves + // `coordinator.snapshot(...)`, the rejection report delegates to `adjudicateRejection`, and in + // production `notify` is `notifyCredentialsExpiredOnce` over the same storage. + function harness(options: { mint?: (current: Creds) => Promise } = {}) { + const kv = fakeKv(); + const instance = new CredentialCoordinator(kv); + const notify = vi.fn(async () => {}); + const mint = vi.fn(options.mint + ?? (async () => ({ token: "minted", expiresAt: Date.now() + 3_600_000 }))); + const account = { + getCredentials: () => instance.snapshot(async current => current, { notify }), + reportCredentialsRejected: + (identity: string) => instance.adjudicateRejection(identity, { refresh: mint, notify }), + }; + const newSource = () => new CredentialSource({ + account: () => account, + isAuthError: error => error instanceof Error && error.message === "401", + expiredMessage: "Reconnect.", + }); + return { coordinator: instance, source: newSource(), newSource, notify, mint }; + } + + /** A provider accepting exactly the given tokens, rejecting everything else as a 401. */ + function providerAccepting(...tokens: string[]) { + return vi.fn(async (creds: Creds) => { + if (!tokens.includes(creds.token)) throw new Error("401"); + return creds.token; + }); + } + + const hour = 3_600_000; + + it("heals a stale bearer invisibly with one mint and no notification", async () => { + const { coordinator, source, notify, mint } = harness(); + coordinator.connect({ token: "stale-bearer", expiresAt: Date.now() + hour }); + const operation = providerAccepting("minted"); + + expect(await source.run(operation, { replayable: true })).toBe("minted"); + + // The whole recovery happened inside the report round trip: one provider mint, the caller + // never saw an error, and the Workshop was never told anything. + expect(operation).toHaveBeenCalledTimes(2); + expect(mint).toHaveBeenCalledOnce(); + expect(notify).not.toHaveBeenCalled(); + expect(coordinator.stored()?.token).toBe("minted"); + }); + + it("spends one mint and one notification on a dead grant under concurrent runs", async () => { + const { coordinator, source, notify, mint } = harness({ + mint: async () => { throw new CredentialsExpiredError("invalid_grant"); }, + }); + coordinator.connect({ token: "dead-bearer", expiresAt: Date.now() + hour }); + + const runs = [ + source.run(async () => { throw new Error("401"); }, { replayable: true }), + source.run(async () => { throw new Error("401"); }, { replayable: true }), + source.run(async () => { throw new Error("401"); }, { replayable: true }), + ]; + for (const run of runs) await expect(run).rejects.toThrow(CredentialsExpiredError); + + // The burst coalesces: one read, one ask, one doomed mint, one Workshop notification. + expect(mint).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledOnce(); + // The grant stays stored until reconnect; the account made its verdict, not a disconnect. + expect(coordinator.stored()?.token).toBe("dead-bearer"); + }); + + it("stops every other facet once the account buries the grant", async () => { + const { coordinator, source, newSource, mint } = harness({ + mint: async () => { throw new CredentialsExpiredError("invalid_grant"); }, + }); + coordinator.connect({ token: "dead-bearer", expiresAt: Date.now() + hour }); + + // A second facet vouches for the grant before anything goes wrong. + const warm = newSource(); + expect(await warm.run(providerAccepting("dead-bearer"))).toBe("dead-bearer"); + expect(await warm.cacheAuthority()).toBe(coordinator.connectionGeneration()); + + await expect(source.run(async () => { throw new Error("401"); }, { replayable: true })) + .rejects.toThrow(CredentialsExpiredError); + + // Nothing about the bearer's own expiry says it is dead, so only the recorded death can stop + // the warm facet vouching for it and a facet that arrives afterwards reading it. + await expect(warm.cacheAuthority()).rejects.toThrow(CredentialsExpiredError); + await expect(newSource().get()).rejects.toThrow(CredentialsExpiredError); + expect(mint).toHaveBeenCalledOnce(); + expect(coordinator.stored()?.token).toBe("dead-bearer"); + + coordinator.connect({ token: "revived", expiresAt: Date.now() + hour }); + expect(await warm.run(providerAccepting("revived"))).toBe("revived"); + expect(await warm.cacheAuthority()).toBe(coordinator.connectionGeneration()); + }); + + it("hands a non-replayable caller a retryable error whose re-entry needs no second mint", async () => { + const { coordinator, source, notify, mint } = harness(); + coordinator.connect({ token: "stale-bearer", expiresAt: Date.now() + hour }); + const operation = providerAccepting("minted"); + + // The branch's old footgun, closed: a stale derived bearer on a non-replayable call heals + // account-side all the same — the caller re-enters instead of retiring a healthy account. + await expect(source.run(operation)).rejects.toThrow(CredentialsChangedError); + expect(await source.run(operation)).toBe("minted"); + + expect(mint).toHaveBeenCalledOnce(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("reports a disconnect landing mid-heal as expiry, not a retryable change", async () => { + const gate = Promise.withResolvers(); + const { coordinator, source, notify, mint } = harness({ mint: () => gate.promise }); + coordinator.connect({ token: "stale-bearer", expiresAt: Date.now() + hour }); + + const run = source.run(async () => { throw new Error("401"); }); + await vi.waitFor(() => expect(mint).toHaveBeenCalled()); + coordinator.clear(); + gate.reject(new Error("502 from token endpoint")); + + // "Retry it" would bounce the caller into a disconnected account; expiry says reconnect. + await expect(run).rejects.toThrow(CredentialsExpiredError); + await expect(run).rejects.toThrow("Reconnect."); + expect(notify).not.toHaveBeenCalled(); + }); + + it("resolves a rejection under a mid-operation reconnect as retryable without healing", async () => { + const { coordinator, source, notify, mint } = harness(); + coordinator.connect({ token: "old", expiresAt: Date.now() + hour }); + const { run: call, release } = await stalledRun(source, { replayable: true }); + + coordinator.connect({ token: "reconnected", expiresAt: Date.now() + hour }); + release(); + + // The rejected identity was already replaced: the moved-past gate answers superseded with no + // mint, and the crossed generation keeps the retry off the new principal — the caller + // re-enters and runs under the reconnect deliberately. + await expect(call).rejects.toThrow(CredentialsChangedError); + expect(mint).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + expect(await source.run(providerAccepting("reconnected"))).toBe("reconnected"); + }); + + it("spends one mint however many facets report their stale bearers", async () => { + const { coordinator, source, newSource, notify, mint } = harness(); + coordinator.connect({ token: "first-bearer", expiresAt: Date.now() + hour }); + const operation = providerAccepting("minted"); + + // A facet's run reads the first bearer and stalls mid-operation. + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const slow = source.run(async creds => { + if (creds.token === "first-bearer") { + entered.resolve(); + await gate.promise; + throw new Error("401"); + } + return operation(creds); + }, { replayable: true }); + await entered.promise; + + // The account rotates in place — a sibling refresh — and a second facet's rejection of the + // rotated bearer heals: the one mint. + await coordinator.rotate(async () => ({ token: "second-bearer", expiresAt: Date.now() + hour })); + expect(await newSource().run(operation, { replayable: true })).toBe("minted"); + expect(mint).toHaveBeenCalledOnce(); + + // The first facet's report names an identity the account moved past twice over: the gate + // answers superseded without minting, and its retry rides the healed grant. + gate.resolve(); + expect(await slow).toBe("minted"); + expect(mint).toHaveBeenCalledOnce(); + expect(notify).not.toHaveBeenCalled(); + }); + + it("caps a heal the provider keeps rejecting at two attempts", async () => { + const { coordinator, source, notify, mint } = harness(); + coordinator.connect({ token: "stale-bearer", expiresAt: Date.now() + hour }); + const operation = providerAccepting(); + + // Every mint succeeds and honestly supersedes the rejected identity, so no verdict ever says + // expired — the source still stops at two executions and hands re-entry to the caller. + await expect(source.run(operation, { replayable: true })) + .rejects.toThrow(CredentialsChangedError); + + expect(operation).toHaveBeenCalledTimes(2); + expect(mint).toHaveBeenCalledTimes(2); + expect(notify).not.toHaveBeenCalled(); + }); + + it("keeps refresh material account-side when getCredentials serves a projection", async () => { + // The coordinator and source type parameters are independent on purpose: the account holds + // the full grant, the source only its public projection. + type Grant = Creds & { refreshSecret: string }; + const grants = new CredentialCoordinator(fakeKv()); + grants.connect({ token: "stale", expiresAt: Date.now() + hour, refreshSecret: "keep-me" }); + const source = new CredentialSource({ + account: () => ({ + getCredentials: async () => { + const { creds, identity, generation } = + await grants.snapshot(async current => current, { notify: async () => {} }); + return { creds: { token: creds.token, expiresAt: creds.expiresAt }, identity, generation }; + }, + reportCredentialsRejected: identity => grants.adjudicateRejection(identity, { + refresh: async current => ({ ...current, token: "minted" }), + notify: async () => {}, + }), + }), + isAuthError: error => error instanceof Error && error.message === "401", + expiredMessage: "Reconnect.", + }); + + const handed: Creds[] = []; + const result = await source.run(async creds => { + handed.push(creds); + if (creds.token !== "minted") throw new Error("401"); + return creds.token; + }, { replayable: true }); + + // The heal ran account-side against the full grant; no read ever carried the secret. + expect(result).toBe("minted"); + expect(grants.stored()).toMatchObject({ token: "minted", refreshSecret: "keep-me" }); + for (const creds of handed) expect(creds).not.toHaveProperty("refreshSecret"); }); }); diff --git a/packages/gatekeeper-kit/__tests__/env.d.ts b/packages/gatekeeper-kit/__tests__/env.d.ts index 71c2cd368c..9945631a2b 100644 --- a/packages/gatekeeper-kit/__tests__/env.d.ts +++ b/packages/gatekeeper-kit/__tests__/env.d.ts @@ -5,15 +5,18 @@ // wrangler.jsonc of its own, so there is no generated `worker-configuration.d.ts` to carry them. import type { TrackerHost } from "./workerd/worker.js"; +import type { ConformanceAccount, ConformanceResource } from "./workerd/conformance/gatekeeper.js"; declare global { namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./workerd/worker.js"); - durableNamespaces: "TrackerHost"; + durableNamespaces: "TrackerHost" | "ConformanceAccount" | "ConformanceResource"; } interface Env { TRACKER_HOST: DurableObjectNamespace; + CONFORMANCE_ACCOUNT: DurableObjectNamespace; + CONFORMANCE_RESOURCE: DurableObjectNamespace; } } } diff --git a/packages/gatekeeper-kit/__tests__/fake-kv.ts b/packages/gatekeeper-kit/__tests__/fake-kv.ts index 184f790dad..491ce29768 100644 --- a/packages/gatekeeper-kit/__tests__/fake-kv.ts +++ b/packages/gatekeeper-kit/__tests__/fake-kv.ts @@ -6,7 +6,7 @@ export type FakeKv = { get(key: string): T | undefined; put(key: string, value: T): void; delete(key: string): void; - list(options: { prefix: string }): Iterable<[string, T]>; + list(options: { prefix: string; startAfter?: string; limit?: number }): Iterable<[string, T]>; /** Test-only: every key ever written, in write order, including repeats. */ readonly writes: string[]; /** Test-only: the keys currently present, lexicographically. */ @@ -29,11 +29,15 @@ export function fakeKv(): FakeKv { values.set(key, structuredClone(value)); }, delete: (key: string) => void values.delete(key), - list: ({ prefix }: { prefix: string }) => - [...values.entries()] - .filter(([key]) => key.startsWith(prefix)) + list: ({ prefix, startAfter, limit }: + { prefix: string; startAfter?: string; limit?: number }) => { + const found = [...values.entries()] + .filter(([key]) => key.startsWith(prefix) + && (startAfter === undefined || key > startAfter)) .toSorted(byKey) - .map(([key, value]) => [key, structuredClone(value)] as [string, T]), + .map(([key, value]) => [key, structuredClone(value)] as [string, T]); + return limit === undefined ? found : found.slice(0, limit); + }, writes, keys: () => [...values.keys()].toSorted(), }; diff --git a/packages/gatekeeper-kit/__tests__/observers.test.ts b/packages/gatekeeper-kit/__tests__/observers.test.ts index ce79675076..75541a345b 100644 --- a/packages/gatekeeper-kit/__tests__/observers.test.ts +++ b/packages/gatekeeper-kit/__tests__/observers.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ApprovalQueue, GatekeeperUserVerifier } from "@gadgets/workshop-shared/gatekeeper"; +import type { + GatekeeperUserVerifier, + ObservationAuthorizer, +} from "@gadgets/workshop-shared/gatekeeper"; import type { RpcStub } from "cloudflare:workers"; import { aclObservers, escapeObservationValue, + isObservationRefused, + OBSERVATION_REFUSED_CODE, ObservationGate, OBSERVER_ATTEMPT_LIFETIME_MS, OBSERVER_DENIED, @@ -11,17 +16,20 @@ import { ObserverTracker, openObservers, privateObservers, - trackedSetObservers, + trackedCollectionObservers, type ObserverKv, type ObserverStrategy, type ObserverTrackerOptions, } from "../src/observers"; -import { fakeKv } from "./fake-kv"; +import { fakeKv, type FakeKv } from "./fake-kv"; function makeKv(): ObserverKv { return fakeKv(); } +const withholdMarkers = (kv: FakeKv) => + kv.keys().filter(key => key.startsWith("observer-withhold-fence:")); + // Fixed ACL verifier that records each batched check. type V = { allowed: string[]; batches: (readonly string[])[] }; @@ -32,9 +40,9 @@ function verifier(...allowed: string[]): V { function tracker(kv: ObserverKv = makeKv(), options: Partial> = {}) { return new ObserverTracker({ kv, - hasSetAccess: async (value, setIds) => { - value.batches.push(setIds); - return setIds.map(setId => value.allowed.includes(setId)); + hasCollectionAccess: async (value, collectionIds) => { + value.batches.push(collectionIds); + return collectionIds.map(collectionId => value.allowed.includes(collectionId)); }, ...options, }); @@ -46,9 +54,15 @@ async function observe(instance: ObserverTracker, sets: string[]) { return check.excludeObservers; } -// Approval queue fake that records authorization requests. -function fakeQueue(authorizeObservation = vi.fn(async () => {})) { - return { authorizeObservation } as unknown as RpcStub; +// Observation authorizer fake that records authorization requests. +function fakeAuthorizer(authorizeObservation = vi.fn(async () => {})) { + return { authorizeObservation } as unknown as RpcStub; +} + +// The mark the overseer carries on a policy refusal. No kernel error class exists yet, so the +// protocol is exercised the way the transport delivers it: an enumerable own `code`. +function refusal(message = "a collaborator may not see this"): Error { + return Object.assign(new Error(message), { code: OBSERVATION_REFUSED_CODE }); } const someUser = {} as Fetcher; @@ -85,7 +99,7 @@ describe("ObserverTracker", () => { it("excludes an observer that lost access to a set it was already shown", async () => { // A verdict recorded at first disclosure must not outlive a provider-side ACL revocation. let revoked = false; - const instance = tracker(makeKv(), { hasSetAccess: async () => [!revoked] }); + const instance = tracker(makeKv(), { hasCollectionAccess: async () => [!revoked] }); await instance.addObserver("x", verifier("a")); expect(await observe(instance, ["a"])).toBeUndefined(); @@ -122,15 +136,15 @@ describe("ObserverTracker", () => { expect(denied.batches).toEqual([["new"]]); }); - it("leaves the pending records of a read that never disclosed", async () => { - // A pending record means the read never committed, so nothing was shown. Keeping it costs a - // tracking slot and denies an observer a set nobody saw, which the whole corpus accepts. + it("keeps the pending records of a read whose outcome is unknown", async () => { + // A lost reply may still have recorded the observation, so the set stays admission-relevant + // and its tracking slot stays consumed -- fail closed. const kv = makeKv(); const instance = tracker(kv); - const refused = await instance.prepareObservation(["secret"]); + const unknown = await instance.prepareObservation(["secret"]); expect(kv.get("observed:secret")).toBe("pending"); - refused.discard?.(); + unknown.abandon?.(); expect(kv.get("observed:secret")).toBe("pending"); // Still admission-relevant, and promoted by the next read that does commit. @@ -141,6 +155,83 @@ describe("ObserverTracker", () => { expect(kv.get("observed:secret")).toBe("observed"); }); + it("reclaims the pending records of a refused read, which recorded nothing", async () => { + const kv = makeKv(); + const instance = tracker(kv, { maxTrackedCollections: 1 }); + const refused = await instance.prepareObservation(["secret"]); + expect(kv.get("observed:secret")).toBe("pending"); + + refused.discard?.(); + expect(kv.get("observed:secret")).toBeUndefined(); + + // The slot is free again, and admission no longer answers for a set nobody was shown. + expect(await observe(instance, ["other"])).toBeUndefined(); + const late = verifier("other"); + await expect(instance.addObserver("late", late)).resolves.toBeUndefined(); + expect(late.batches).toEqual([["other"]]); + }); + + it("keeps a refused read's record while a concurrent read still depends on it", async () => { + const kv = makeKv(); + const instance = tracker(kv); + const refused = await instance.prepareObservation(["secret"]); + const disclosing = await instance.prepareObservation(["secret"]); + + // The second read is still awaiting the overseer under this marker: reclaiming it here would + // drop the fence its admission checks rely on. + refused.discard?.(); + expect(kv.get("observed:secret")).toBe("pending"); + + disclosing.commit(); + expect(kv.get("observed:secret")).toBe("observed"); + }); + + it("reclaims a record only after every claimant has refused", async () => { + const kv = makeKv(); + const instance = tracker(kv, { maxTrackedCollections: 1 }); + const first = await instance.prepareObservation(["secret"]); + const second = await instance.prepareObservation(["secret"]); + + // The creator refuses first, so reclaiming falls to whoever settles last -- otherwise the slot + // stays spent for a set no read ever disclosed. + first.discard?.(); + expect(kv.get("observed:secret")).toBe("pending"); + + second.discard?.(); + expect(kv.get("observed:secret")).toBeUndefined(); + expect(await observe(instance, ["other"])).toBeUndefined(); + }); + + it("keeps a record a concurrent read's unknown outcome may have had recorded", async () => { + // The dangerous ordering: the sibling settles first with an unknown outcome, so the overseer + // may hold its record, and the creator's later refusal must not reclaim the marker anyway. + const kv = makeKv(); + const instance = tracker(kv); + const creator = await instance.prepareObservation(["secret"]); + const unknown = await instance.prepareObservation(["secret"]); + + unknown.abandon?.(); + creator.discard?.(); + expect(kv.get("observed:secret")).toBe("pending"); + + // Still admission-relevant, which is the whole point of keeping it. + const denied = verifier(); + await expect(instance.addObserver("late", denied)).rejects.toThrow(/does not have access/); + expect(denied.batches).toEqual([["secret"]]); + }); + + it("never reclaims a marker a later read finds already pending", async () => { + // A fresh activation cannot know whether the read that wrote a pending marker was recorded, so + // only the read that created one may ever reclaim it. + const kv = makeKv(); + const stranded = tracker(kv); + (await stranded.prepareObservation(["secret"])).abandon?.(); + + const revived = tracker({ ...kv }); + (await revived.prepareObservation(["secret"])).discard?.(); + expect(kv.get("observed:secret")).toBe("pending"); + }); + it("refuses an observer past the cap, and still re-admits one it already answers for", async () => { // Every observer costs a verifier call per read, and a request may make only 32 Worker // invocations, so admission is where the ceiling has to be legible. @@ -160,9 +251,9 @@ describe("ObserverTracker", () => { let batches = 0; const instance = tracker(kv, { maxObservers: 1, - hasSetAccess: async (value, setIds) => { + hasCollectionAccess: async (value, collectionIds) => { if (++batches === 1) await gate.promise; - return setIds.map(setId => value.allowed.includes(setId)); + return collectionIds.map(collectionId => value.allowed.includes(collectionId)); }, }); await observe(instance, ["a"]); @@ -206,9 +297,9 @@ describe("ObserverTracker", () => { let batches = 0; const instance = tracker(makeKv(), { maxObservers: 1, - hasSetAccess: async (value, setIds) => { + hasCollectionAccess: async (value, collectionIds) => { if (++batches === 1) await gate.promise; - return setIds.map(setId => value.allowed.includes(setId)); + return collectionIds.map(collectionId => value.allowed.includes(collectionId)); }, }); await observe(instance, ["a"]); @@ -239,10 +330,10 @@ describe("ObserverTracker", () => { let batches = 0; const instance = new ObserverTracker({ kv, - hasSetAccess: async (value, setIds) => { - value.batches.push(setIds); + hasCollectionAccess: async (value, collectionIds) => { + value.batches.push(collectionIds); if (++batches === 1) await gate.promise; - return setIds.map(setId => value.allowed.includes(setId)); + return collectionIds.map(collectionId => value.allowed.includes(collectionId)); }, }); await observe(tracker(kv), ["first"]); @@ -273,7 +364,7 @@ describe("ObserverTracker", () => { // The message reaches the denied collaborator verbatim; the set id is for diagnostics only. const denied: string[] = []; const instance = tracker(makeKv(), { - denyMessage: setId => (denied.push(setId), OBSERVER_DENIED), + denyMessage: collectionId => (denied.push(collectionId), OBSERVER_DENIED), }); await observe(instance, ["a", "b"]); @@ -286,7 +377,7 @@ describe("ObserverTracker", () => { kv.put("observed:old", true); const asked: string[][] = []; const instance = tracker(kv, { - hasSetAccess: async (_value, setIds) => (asked.push([...setIds]), setIds.map(() => true)), + hasCollectionAccess: async (_value, collectionIds) => (asked.push([...collectionIds]), collectionIds.map(() => true)), }); // The set counts as already revealed, so an incoming observer is checked against it... @@ -303,7 +394,7 @@ describe("ObserverTracker", () => { const admitted = tracker(kv); await admitted.addObserver("x", verifier()); - const ragged = new ObserverTracker({ kv, hasSetAccess: async () => [] }); + const ragged = new ObserverTracker({ kv, hasCollectionAccess: async () => [] }); expect((await ragged.prepareObservation(["secret"])).excludeObservers).toEqual(["x"]); }); @@ -314,11 +405,11 @@ describe("ObserverTracker", () => { const kv = makeKv(); const chunking = new ObserverTracker({ kv, - hasSetAccess: async (value, setIds) => { - const batch = setIds as string[]; + hasCollectionAccess: async (value, collectionIds) => { + const batch = collectionIds as string[]; const verdicts: boolean[] = []; while (batch.length > 0) { - for (const setId of batch.splice(0, 2)) verdicts.push(value.allowed.includes(setId)); + for (const collectionId of batch.splice(0, 2)) verdicts.push(value.allowed.includes(collectionId)); } return verdicts; }, @@ -345,7 +436,7 @@ describe("ObserverTracker", () => { const broken = new ObserverTracker({ kv, vendorId: "acme", - hasSetAccess: async () => { throw new Error("no such Durable Object"); }, + hasCollectionAccess: async () => { throw new Error("no such Durable Object"); }, }); const check = await broken.prepareObservation(["secret"]); @@ -372,9 +463,9 @@ describe("ObserverTracker", () => { const kv = makeKv(); const gate = Promise.withResolvers(); const instance = tracker(kv, { - hasSetAccess: async (value, setIds) => { + hasCollectionAccess: async (value, collectionIds) => { await gate.promise; - return setIds.map(setId => value.allowed.includes(setId)); + return collectionIds.map(collectionId => value.allowed.includes(collectionId)); }, }); await observe(instance, ["a"]); @@ -392,7 +483,7 @@ describe("ObserverTracker", () => { it("keeps the observed-set key family a port already has in storage", async () => { const kv = makeKv(); - const instance = tracker(kv, { setPrefix: "observedProject:" }); + const instance = tracker(kv, { collectionPrefix: "observedProject:" }); await instance.addObserver("x", verifier("p1")); await observe(instance, ["p1"]); @@ -402,8 +493,8 @@ describe("ObserverTracker", () => { it("refuses a set prefix overlapping the observer family, either direction", () => { // Under an observer prefix, containing one, and the empty prefix that scans every family. - for (const setPrefix of ["observer:sets:", "observer-attempt:x", "obs", ""]) { - expect(() => new ObserverTracker({ kv: makeKv(), setPrefix, hasSetAccess: async () => [] })) + for (const collectionPrefix of ["observer:sets:", "observer-attempt:x", "obs", ""]) { + expect(() => new ObserverTracker({ kv: makeKv(), collectionPrefix, hasCollectionAccess: async () => [] })) .toThrow(/overlaps the reserved prefix/); } }); @@ -411,7 +502,7 @@ describe("ObserverTracker", () => { it("denies when the oracle answers fewer sets than it was asked about", async () => { const kv = makeKv(); await observe(tracker(kv), ["a"]); - const short = new ObserverTracker({ kv, hasSetAccess: async () => [] }); + const short = new ObserverTracker({ kv, hasCollectionAccess: async () => [] }); await expect(short.addObserver("x", verifier("a"))).rejects.toThrow(/does not have access/); }); @@ -423,7 +514,7 @@ describe("ObserverTracker", () => { // extra entries — including a `false` — are never looked at. A length the oracle disagrees // about means the verdicts are not the answers to these questions. const overlong = new ObserverTracker({ - kv, hasSetAccess: async () => [true, true, false], + kv, hasCollectionAccess: async () => [true, true, false], }); await expect(overlong.addObserver("x", verifier("a"))).rejects.toThrow(/does not have access/); @@ -436,14 +527,14 @@ describe("ObserverTracker", () => { await admitted.addObserver("x", verifier()); const overlong = new ObserverTracker({ - kv, hasSetAccess: async () => [true, true], + kv, hasCollectionAccess: async () => [true, true], }); expect((await overlong.prepareObservation(["secret"])).excludeObservers).toEqual(["x"]); }); it("refuses to reveal more sets than it can keep verifiable", async () => { const kv = makeKv(); - const instance = tracker(kv, { maxTrackedSets: 2 }); + const instance = tracker(kv, { maxTrackedCollections: 2 }); await observe(instance, ["a", "b"]); await expect(instance.prepareObservation(["c"])).rejects.toThrow(/most it can track/); @@ -460,11 +551,11 @@ describe("ObserverTracker", () => { const instance = new ObserverTracker({ kv, concurrency: 2, - hasSetAccess: async (_verifier, setIds) => { + hasCollectionAccess: async (_verifier, collectionIds) => { peak = Math.max(peak, ++inFlight); await Promise.resolve(); inFlight -= 1; - return setIds.map(() => true); + return collectionIds.map(() => true); }, }); // Admitted before anything is tracked, so no oracle call happens here. @@ -475,8 +566,8 @@ describe("ObserverTracker", () => { }); it("refuses a cap or window that cannot make progress", () => { - for (const options of [{ maxTrackedSets: 0 }, { concurrency: 0 }, { concurrency: 1.5 }]) { - expect(() => tracker(makeKv(), options)).toThrow(/must be a positive integer/); + for (const options of [{ maxTrackedCollections: 0 }, { concurrency: 0 }, { concurrency: 1.5 }]) { + expect(() => tracker(makeKv(), options)).toThrow(/must be a positive safe integer/); } }); @@ -494,9 +585,9 @@ describe("ObserverTracker", () => { const kv = makeKv(); const asked: string[][] = []; const instance = tracker(kv, { - setPrefix: "observedItem:", - canonicalSetId: setId => setId.replaceAll("-", ""), - hasSetAccess: async (_value, setIds) => (asked.push([...setIds]), setIds.map(() => true)), + collectionPrefix: "observedItem:", + canonicalCollectionId: collectionId => collectionId.replaceAll("-", ""), + hasCollectionAccess: async (_value, collectionIds) => (asked.push([...collectionIds]), collectionIds.map(() => true)), }); // The hyphenated and bare spellings of one id are the same tracked set, not two. @@ -513,7 +604,7 @@ describe("ObserverTracker", () => { it("canonicalizes exactly once, so even a non-idempotent transform agrees with itself", async () => { const kv = makeKv(); // Encoding twice would store `a%252Fb` while the forward check asked about `a%2Fb`. - const instance = tracker(kv, { setPrefix: "observedFile:", canonicalSetId: encodeURIComponent }); + const instance = tracker(kv, { collectionPrefix: "observedFile:", canonicalCollectionId: encodeURIComponent }); const observer = verifier("a%2Fb"); await instance.addObserver("x", observer); @@ -563,9 +654,9 @@ describe("observer strategies", () => { }); it("C: tracked-set bindings expose the tracker's prepare()", async () => { - const strategy = trackedSetObservers({ + const strategy = trackedCollectionObservers({ kv: makeKv(), - hasSetAccess: async (_v, setIds) => setIds.map(() => false), + hasCollectionAccess: async (_v, collectionIds) => collectionIds.map(() => false), }); await strategy.addObserver("x", someUser); @@ -604,6 +695,23 @@ describe("escapeObservationValue", () => { }); }); +describe("isObservationRefused", () => { + it("matches the mark on a rebuilt error's `code`, as the transport delivers it", () => { + expect(isObservationRefused(refusal())).toBe(true); + }); + + it("matches the mark on `name`, as a thrown class carries it", () => { + const thrown = new Error("refused"); + thrown.name = OBSERVATION_REFUSED_CODE; + expect(isObservationRefused(thrown)).toBe(true); + }); + + it("reads an unmarked failure as an unknown outcome", () => { + expect(isObservationRefused(new Error("connection lost"))).toBe(false); + expect(isObservationRefused(OBSERVATION_REFUSED_CODE)).toBe(false); + }); +}); + describe("ObservationGate", () => { const read = { title: "Read", description: "Read a row" }; @@ -615,11 +723,12 @@ describe("ObservationGate", () => { // authorizes first and awaits the check afterwards -- the ordering would look identical. const preparing = Promise.withResolvers(); const strategy: ObserverStrategy = { + aclChecks: "per-read", addObserver: async () => {}, removeObserver: async () => {}, prepareWithheld: () => ({ commit() {} }), - prepare: async setIds => { - order.push(`prepare:start:${setIds.join(",")}`); + prepare: async collectionIds => { + order.push(`prepare:start:${collectionIds.join(",")}`); await preparing.promise; order.push("prepare:end"); return { @@ -630,8 +739,8 @@ describe("ObservationGate", () => { }, }; - const authorizing = new ObservationGate(fakeQueue(authorizeObservation), strategy) - .authorize(read, { kind: "sets", ids: ["p1"] }); + const authorizing = new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) + .authorize(read, { kind: "collections", ids: ["p1"] }); // `prepare` ran synchronously up to its await, and nothing else may have happened yet. expect(order).toEqual(["prepare:start:p1"]); @@ -647,38 +756,54 @@ describe("ObservationGate", () => { it("releases the queue dup it was handed", () => { const dispose = vi.fn(); const queue = { authorizeObservation: async () => {}, [Symbol.dispose]: dispose } as unknown as - RpcStub; + RpcStub; new ObservationGate(queue, openObservers())[Symbol.dispose](); expect(dispose).toHaveBeenCalledOnce(); }); - it("shares its stub so a session staging actions needs no second dup", () => { - const queue = { authorizeObservation: async () => {} } as unknown as RpcStub; - const gate = new ObservationGate(queue, openObservers()); - - // The same reference, not a dup: ownership (and release) stays with the gate. The type is - // narrowed to actions only, so observations cannot skip the strategy's exclusions. - expect(gate.actions).toBe(queue); - }); - it("discards rather than commits when the overseer refuses, keeping its error", async () => { const commit = vi.fn(); const discard = vi.fn(); + const abandon = vi.fn(); const strategy: ObserverStrategy = { + aclChecks: "per-read", addObserver: async () => {}, removeObserver: async () => {}, prepareWithheld: () => ({ commit() {} }), - prepare: async () => ({ excludeObservers: ["limited"], commit, discard }), + prepare: async () => ({ excludeObservers: ["limited"], commit, discard, abandon }), }; - const authorizeObservation = vi.fn(async () => { throw new Error("cannot hide observation"); }); + const authorizeObservation = vi.fn(async () => { throw refusal("cannot hide observation"); }); await expect( - new ObservationGate(fakeQueue(authorizeObservation), strategy) - .authorize(read, { kind: "sets", ids: ["p1"] }), + new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) + .authorize(read, { kind: "collections", ids: ["p1"] }), ).rejects.toThrow("cannot hide observation"); expect(commit).not.toHaveBeenCalled(); expect(discard).toHaveBeenCalledOnce(); + expect(abandon).not.toHaveBeenCalled(); + }); + + it("abandons prepared state when the outcome is unknown, keeping durable fences", async () => { + // An unmarked failure may be a lost reply to an observation the overseer already recorded, so + // nothing prepared may be reclaimed. + const discard = vi.fn(); + const abandon = vi.fn(); + const strategy: ObserverStrategy = { + aclChecks: "per-read", + addObserver: async () => {}, + removeObserver: async () => {}, + prepareWithheld: () => ({ commit() {} }), + prepare: async () => ({ commit() {}, discard, abandon }), + }; + const authorizeObservation = vi.fn(async () => { throw new Error("connection lost"); }); + + await expect( + new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) + .authorize(read, { kind: "collections", ids: ["p1"] }), + ).rejects.toThrow("connection lost"); + expect(discard).not.toHaveBeenCalled(); + expect(abandon).toHaveBeenCalledOnce(); }); it("passes the description through untouched for a strategy that tracks nothing", async () => { @@ -686,41 +811,72 @@ describe("ObservationGate", () => { // The caller's own object, so a copy that added or dropped a field fails this. const description = { title: "Read", description: "Read a row" }; - await new ObservationGate(fakeQueue(authorizeObservation), openObservers()) - .authorize(description, { kind: "sets", ids: ["p1"] }); + await new ObservationGate(fakeAuthorizer(authorizeObservation), openObservers()) + .authorize(description, { kind: "baseline" }); expect(authorizeObservation).toHaveBeenCalledWith(description); }); - it("refuses a sets scope naming no set, which meant two opposite things in the corpus", async () => { + it("refuses collection ids under a strategy that cannot check them", async () => { + // The hierarchical trap: org-level admission, project-level ACLs. Correctly supplying the + // project ids used to disclose them with nothing consulted. + const authorizeObservation = vi.fn(async () => {}); + const acl = aclObservers({ hasAccess: async () => true }); + + await expect(new ObservationGate(fakeAuthorizer(authorizeObservation), acl) + .authorize(read, { kind: "collections", ids: ["p1"] })) + .rejects.toThrow(/cannot enforce collection ACLs/); + expect(authorizeObservation).not.toHaveBeenCalled(); + }); + + it("refuses collection ids under an open strategy, which draws no distinction to enforce", async () => { const authorizeObservation = vi.fn(async () => {}); - const strategy = trackedSetObservers({ kv: makeKv(), hasSetAccess: async () => [] }); - await expect(new ObservationGate(fakeQueue(authorizeObservation), strategy) - .authorize(read, { kind: "sets", ids: [] })).rejects.toThrow(/at least one set id/); + await expect(new ObservationGate(fakeAuthorizer(authorizeObservation), openObservers()) + .authorize(read, { kind: "collections", ids: ["p1"] })) + .rejects.toThrow(/cannot enforce collection ACLs/); + expect(authorizeObservation).not.toHaveBeenCalled(); + }); + + it("allows a collection scope under a private strategy, where nobody is admitted to exclude", async () => { + // Refusing here would force a correct configuration to misdescribe what it read. + const authorizeObservation = vi.fn(async () => {}); + + await new ObservationGate(fakeAuthorizer(authorizeObservation), privateObservers("no sharing")) + .authorize(read, { kind: "collections", ids: ["p1"] }); + + expect(authorizeObservation).toHaveBeenCalledWith(read); + }); + + it("refuses a collections scope naming no collection, which meant two opposite things in the corpus", async () => { + const authorizeObservation = vi.fn(async () => {}); + const strategy = trackedCollectionObservers({ kv: makeKv(), hasCollectionAccess: async () => [] }); + + await expect(new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) + .authorize(read, { kind: "collections", ids: [] })).rejects.toThrow(/at least one collection id/); expect(authorizeObservation).not.toHaveBeenCalled(); }); it("asks the strategy nothing for a read the admission baseline covers", async () => { const authorizeObservation = vi.fn(async () => {}); - const hasSetAccess = vi.fn(async (_v: V, setIds: readonly string[]) => setIds.map(() => false)); - const strategy = trackedSetObservers({ kv: makeKv(), hasSetAccess }); + const hasCollectionAccess = vi.fn(async (_v: V, collectionIds: readonly string[]) => collectionIds.map(() => false)); + const strategy = trackedCollectionObservers({ kv: makeKv(), hasCollectionAccess }); await strategy.addObserver("x", someUser); - await new ObservationGate(fakeQueue(authorizeObservation), strategy) + await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) .authorize(read, { kind: "baseline" }); expect(authorizeObservation).toHaveBeenCalledWith(read); - expect(hasSetAccess).not.toHaveBeenCalled(); + expect(hasCollectionAccess).not.toHaveBeenCalled(); }); it("withholds a read no set id describes from every admitted observer", async () => { const authorizeObservation = vi.fn(async () => {}); - const strategy = trackedSetObservers({ kv: makeKv(), hasSetAccess: async () => [] }); + const strategy = trackedCollectionObservers({ kv: makeKv(), hasCollectionAccess: async () => [] }); await strategy.addObserver("x", someUser); await strategy.addObserver("y", someUser); - await new ObservationGate(fakeQueue(authorizeObservation), strategy) + await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) .authorize(read, { kind: "withholdFromObservers" }); expect(authorizeObservation).toHaveBeenCalledWith({ ...read, excludeObservers: ["x", "y"] }); @@ -732,15 +888,15 @@ describe("ObservationGate", () => { const authorizeObservation = vi.fn(async () => {}); const gate = Promise.withResolvers(); let admissions = 0; - const strategy = trackedSetObservers({ + const strategy = trackedCollectionObservers({ kv: makeKv(), verifyBaseline: async () => { if (++admissions === 2) await gate.promise; }, - hasSetAccess: async () => [], + hasCollectionAccess: async () => [], }); await strategy.addObserver("settled", someUser); const joining = strategy.addObserver("late", someUser); - await new ObservationGate(fakeQueue(authorizeObservation), strategy) + await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) .authorize(read, { kind: "withholdFromObservers" }); expect(authorizeObservation) @@ -752,61 +908,83 @@ describe("ObservationGate", () => { it("closes admission after a withheld read, which no later verification can clear", async () => { // The read registered no set, so nothing can establish a later candidate was entitled to it. const kv = makeKv(); - const strategy = trackedSetObservers({ kv, hasSetAccess: async () => [] }); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); - await new ObservationGate(fakeQueue(), strategy) + await new ObservationGate(fakeAuthorizer(), strategy) .authorize(read, { kind: "withholdFromObservers" }); await expect(strategy.addObserver("late", someUser)).rejects.toThrow(OBSERVER_WITHHELD); // Durable, and it stages no attempt record a concurrent read would have to withhold from. - expect(new ObserverTracker({ kv, hasSetAccess: async () => [] }).observerIds()).toEqual([]); + expect(new ObserverTracker({ kv, hasCollectionAccess: async () => [] }).observerIds()).toEqual([]); }); it("reopens admission when the overseer refuses a withheld read", async () => { // The overseer refuses whenever an excluded observer is still a collaborator, so this is the // ordinary answer, not an outage. Nothing was disclosed, so nothing may be latched. - const kv = makeKv(); - const strategy = trackedSetObservers({ kv, hasSetAccess: async () => [] }); - const refusing = fakeQueue(vi.fn(async () => { - throw new Error("a collaborator may not see this"); - })); + const kv = fakeKv(); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); + const refusing = fakeAuthorizer(vi.fn(async () => { throw refusal(); })); await expect(new ObservationGate(refusing, strategy) .authorize(read, { kind: "withholdFromObservers" })) .rejects.toThrow("a collaborator may not see this"); + expect(withholdMarkers(kv)).toEqual([]); + expect(kv.get("observer-withhold-latch")).toBeUndefined(); await expect(strategy.addObserver("late", someUser)).resolves.toBeUndefined(); }); + it("latches the fence at once when a withheld read's outcome is unknown", async () => { + // The overseer may already hold the record, so the fence is permanent from that moment. It is + // written now rather than left as a marker for the next admission: with no admission the + // markers would accumulate, one per ambiguous failure, for the life of the binding. + const kv = fakeKv(); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); + const failing = fakeAuthorizer(vi.fn(async () => { throw new Error("connection lost"); })); + + for (const _attempt of [1, 2, 3]) { + await expect(new ObservationGate(failing, strategy) + .authorize(read, { kind: "withholdFromObservers" })).rejects.toThrow("connection lost"); + } + + expect(withholdMarkers(kv)).toEqual([]); + expect(kv.get("observer-withhold-latch")).toBe(true); + await expect(strategy.addObserver("late", someUser)).rejects.toThrow(OBSERVER_WITHHELD); + }); + it("fences admission while a withheld read is still awaiting the overseer", async () => { - // The exclusion list went out before this candidate existed. - const kv = makeKv(); - const strategy = trackedSetObservers({ kv, hasSetAccess: async () => [] }); + // The exclusion list went out before this candidate existed. The marker is still owned, so + // admission may not compact it -- the case an age rule would wrongly promote. + const kv = fakeKv(); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); const overseer = Promise.withResolvers(); - const authorizing = new ObservationGate(fakeQueue(vi.fn(() => overseer.promise)), strategy) + const authorizing = new ObservationGate(fakeAuthorizer(vi.fn(() => overseer.promise)), strategy) .authorize(read, { kind: "withholdFromObservers" }); await expect(strategy.addObserver("late", someUser)).rejects.toThrow(OBSERVER_WITHHELD); + expect(withholdMarkers(kv)).toHaveLength(1); + expect(kv.get("observer-withhold-latch")).toBeUndefined(); + overseer.resolve(); await authorizing; }); it("holds the fence for a second withheld read when the first is refused", async () => { const kv = makeKv(); - const strategy = trackedSetObservers({ kv, hasSetAccess: async () => [] }); - const refusal = Promise.withResolvers(); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); + const refused = Promise.withResolvers(); const overseer = Promise.withResolvers(); - const refused = new ObservationGate(fakeQueue(vi.fn(() => refusal.promise)), strategy) + const reclaiming = new ObservationGate(fakeAuthorizer(vi.fn(() => refused.promise)), strategy) .authorize(read, { kind: "withholdFromObservers" }); - const surviving = new ObservationGate(fakeQueue(vi.fn(() => overseer.promise)), strategy) + const surviving = new ObservationGate(fakeAuthorizer(vi.fn(() => overseer.promise)), strategy) .authorize(read, { kind: "withholdFromObservers" }); - refusal.reject(new Error("a collaborator may not see this")); - await expect(refused).rejects.toThrow("a collaborator may not see this"); + // A marked refusal reclaims its own marker, so only the second read's fence is left. + refused.reject(refusal()); + await expect(reclaiming).rejects.toThrow("a collaborator may not see this"); - // The second read is still awaiting the overseer, so its fence must have survived the first. await expect(strategy.addObserver("late", someUser)).rejects.toThrow(OBSERVER_WITHHELD); overseer.resolve(); await surviving; @@ -819,13 +997,13 @@ describe("ObservationGate", () => { const failing: ObserverKv = { ...kv, put: (key, value) => { - if (key === "observer-withheld") throw new Error("storage unavailable"); + if (key === "observer-withhold-latch") throw new Error("storage unavailable"); kv.put(key, value); }, }; - const strategy = trackedSetObservers({ kv: failing, hasSetAccess: async () => [] }); + const strategy = trackedCollectionObservers({ kv: failing, hasCollectionAccess: async () => [] }); - await expect(new ObservationGate(fakeQueue(vi.fn(async () => {})), strategy) + await expect(new ObservationGate(fakeAuthorizer(vi.fn(async () => {})), strategy) .authorize(read, { kind: "withholdFromObservers" })).rejects.toThrow("storage unavailable"); await expect(strategy.addObserver("later", someUser)).rejects.toThrow(OBSERVER_WITHHELD); @@ -834,13 +1012,16 @@ describe("ObservationGate", () => { it("fences admission durably before the overseer is asked", async () => { // The overseer records the description before its reply reaches us, so an activation dying // mid-authorize must leave admission closed for whatever isolate comes next. - const kv = makeKv(); - const strategy = trackedSetObservers({ kv, hasSetAccess: async () => [] }); + const kv = fakeKv(); + const strategy = trackedCollectionObservers({ kv, hasCollectionAccess: async () => [] }); strategy.prepareWithheld(); // Never settled: the activation died awaiting the overseer. - // A fresh tracker over another handle on the same storage: only a durable fence reaches it. - const revived = trackedSetObservers({ kv: { ...kv }, hasSetAccess: async () => [] }); + // A fresh tracker over another handle on the same storage: only a durable fence reaches it, + // and the marker it cannot own is promoted rather than scanned forever. + const revived = trackedCollectionObservers({ kv: { ...kv }, hasCollectionAccess: async () => [] }); await expect(revived.addObserver("late", someUser)).rejects.toThrow(OBSERVER_WITHHELD); + expect(withholdMarkers(kv)).toEqual([]); + expect(kv.get("observer-withhold-latch")).toBe(true); }); it("takes no fence when enumerating observers fails", async () => { @@ -853,9 +1034,9 @@ describe("ObservationGate", () => { return kv.list(options); }, }; - const strategy = trackedSetObservers({ kv: failing, hasSetAccess: async () => [] }); + const strategy = trackedCollectionObservers({ kv: failing, hasCollectionAccess: async () => [] }); - await expect(new ObservationGate(fakeQueue(vi.fn(async () => {})), strategy) + await expect(new ObservationGate(fakeAuthorizer(vi.fn(async () => {})), strategy) .authorize(read, { kind: "withholdFromObservers" })).rejects.toThrow("storage unavailable"); scan = false; @@ -869,7 +1050,7 @@ describe("ObservationGate", () => { const strategy = aclObservers({ hasAccess: async () => true }); await strategy.addObserver("x", someUser); - await expect(new ObservationGate(fakeQueue(authorizeObservation), strategy) + await expect(new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) .authorize(read, { kind: "withholdFromObservers" })).rejects.toThrow(/shares every read/); expect(authorizeObservation).not.toHaveBeenCalled(); @@ -878,14 +1059,15 @@ describe("ObservationGate", () => { it("leaves the caller's prohibitAllSharing alone, being a gadget-wide escalation", async () => { const authorizeObservation = vi.fn(async () => {}); const strategy: ObserverStrategy = { + aclChecks: "per-read", addObserver: async () => {}, removeObserver: async () => {}, prepareWithheld: () => ({ commit() {} }), prepare: async () => ({ excludeObservers: ["limited"], commit() {}, discard() {} }), }; - await new ObservationGate(fakeQueue(authorizeObservation), strategy) - .authorize({ ...read, prohibitAllSharing: true }, { kind: "sets", ids: ["p1"] }); + await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) + .authorize({ ...read, prohibitAllSharing: true }, { kind: "collections", ids: ["p1"] }); expect(authorizeObservation).toHaveBeenCalledWith({ ...read, @@ -893,4 +1075,16 @@ describe("ObservationGate", () => { excludeObservers: ["limited"], }); }); + + it("reaches the git cache through the gate", async () => { + // A gatekeeper returning commit ids must advertise them. Without this it would have to keep a + // raw queue stub, which is the bypass the gate exists to prevent. + const cache = { advertiseCommit: vi.fn() }; + const getGitCache = vi.fn(async () => cache); + const gate = new ObservationGate( + { getGitCache } as unknown as RpcStub, openObservers()); + + expect(await gate.getGitCache()).toBe(cache); + expect(getGitCache).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/gatekeeper-kit/__tests__/preview-oauth.test.ts b/packages/gatekeeper-kit/__tests__/preview-oauth.test.ts index 46e92e8639..386cd9cc81 100644 --- a/packages/gatekeeper-kit/__tests__/preview-oauth.test.ts +++ b/packages/gatekeeper-kit/__tests__/preview-oauth.test.ts @@ -211,12 +211,14 @@ describe("PreviewOAuth", () => { )).rejects.toBeInstanceOf(PreviewOAuthConfigurationError); }); - it("relays only the provider result and unchanged signed state", async () => { + it("relays the standard provider result set and unchanged signed state", async () => { const oauth = new PreviewOAuth({ callbackUri: STABLE_CALLBACK, env: STABLE_ENV }); const encoded = await signedState(PREVIEW_CALLBACK); const callback = new URL(STABLE_CALLBACK); callback.searchParams.set("error", "access_denied"); - callback.searchParams.set("error_description", "provider-secret"); + callback.searchParams.set("error_description", "The user denied the request"); + callback.searchParams.set("error_uri", "https://provider.example/errors/access_denied"); + callback.searchParams.set("iss", "https://provider.example"); callback.searchParams.set("scope", "private-scope"); callback.searchParams.set("state", encoded); const result = await oauth.handleCallback(callback); @@ -227,10 +229,66 @@ describe("PreviewOAuth", () => { expect(location.origin + location.pathname).toBe(PREVIEW_CALLBACK); expect(location.searchParams.get("error")).toBe("access_denied"); expect(location.searchParams.get("state")).toBe(encoded); - expect(location.searchParams.has("error_description")).toBe(false); + // The error triple is what the preview's own failure page has to work from, and `iss` is + // RFC 9207 mix-up defense: dropping it would disarm the check on the leg that runs it. + expect(location.searchParams.get("error_description")).toBe("The user denied the request"); + expect(location.searchParams.get("error_uri")) + .toBe("https://provider.example/errors/access_denied"); + expect(location.searchParams.get("iss")).toBe("https://provider.example"); + // Anything the provider adds that this deployment did not name stays behind. expect(location.searchParams.has("scope")).toBe(false); }); + it("forwards a deployment-configured extra parameter, and nothing beside it", async () => { + const oauth = new PreviewOAuth({ + callbackUri: STABLE_CALLBACK, + env: STABLE_ENV, + relayParams: ["authuser"], + }); + const encoded = await signedState(PREVIEW_CALLBACK); + const callback = new URL(STABLE_CALLBACK); + callback.searchParams.set("code", "authorization-code"); + callback.searchParams.set("authuser", "2"); + callback.searchParams.set("prompt", "consent"); + callback.searchParams.set("state", encoded); + const result = await oauth.handleCallback(callback); + if (result.kind !== "relay") throw new Error("Expected a relay response"); + const location = new URL(result.response.headers.get("location") ?? ""); + + expect(location.searchParams.get("authuser")).toBe("2"); + expect(location.searchParams.has("prompt")).toBe(false); + }); + + it("refuses relay parameters the kit owns, already forwards, or repeats", () => { + // A repeat would append each provider occurrence once per listing, manufacturing duplicates + // the provider never sent. + for (const relayParams of [["state"], ["code"], ["iss"], ["authuser", "authuser"]]) { + expect(() => new PreviewOAuth({ callbackUri: STABLE_CALLBACK, env: STABLE_ENV, relayParams })) + .toThrow(PreviewOAuthConfigurationError); + } + }); + + it("relays every occurrence of a forwarded parameter, empty values included", async () => { + // The preview's own RFC 9207 check decides whether an issuer is acceptable; collapsing a + // duplicate or dropping an empty one would hide the ambiguity from the code that enforces it. + const oauth = new PreviewOAuth({ callbackUri: STABLE_CALLBACK, env: STABLE_ENV }); + const encoded = await signedState(PREVIEW_CALLBACK); + const callback = new URL(STABLE_CALLBACK); + callback.searchParams.append("iss", "https://provider.example"); + callback.searchParams.append("iss", "https://attacker.example"); + callback.searchParams.append("error_description", ""); + callback.searchParams.set("state", encoded); + const result = await oauth.handleCallback(callback); + if (result.kind !== "relay") throw new Error("Expected a relay response"); + const location = new URL(result.response.headers.get("location") ?? ""); + + expect(location.searchParams.getAll("iss")) + .toEqual(["https://provider.example", "https://attacker.example"]); + expect(location.searchParams.getAll("error_description")).toEqual([""]); + // Still exactly one state, and it is the kit's. + expect(location.searchParams.getAll("state")).toEqual([encoded]); + }); + it("relays successful callbacks behind a shared router path", async () => { const stableCallback = "https://router.example.workers.dev/gatekeeper/google/oauth"; const previewCallback = "https://preview-router.example.workers.dev/gatekeeper/google/oauth"; diff --git a/packages/gatekeeper-kit/__tests__/response-body.test.ts b/packages/gatekeeper-kit/__tests__/response-body.test.ts index 1fadb8c080..028272ddc6 100644 --- a/packages/gatekeeper-kit/__tests__/response-body.test.ts +++ b/packages/gatekeeper-kit/__tests__/response-body.test.ts @@ -43,8 +43,8 @@ describe("readTextCapped", () => { const { body } = streamed(["x".repeat(64)]); const response = new Response(body); - await expect(readTextCapped(response, NaN)).rejects.toThrow(/positive integer/); - await expect(readTextCapped(response, Infinity)).rejects.toThrow(/positive integer/); + await expect(readTextCapped(response, NaN)).rejects.toThrow(/positive safe integer/); + await expect(readTextCapped(response, Infinity)).rejects.toThrow(/positive safe integer/); // The body is untouched: the same response still reads in full under a real cap. expect(body.locked).toBe(false); expect(await readTextCapped(response, 128)).toBe("x".repeat(64)); diff --git a/packages/gatekeeper-kit/__tests__/simulation.test.ts b/packages/gatekeeper-kit/__tests__/simulation.test.ts index f4c8fbde2e..7132a5c5cd 100644 --- a/packages/gatekeeper-kit/__tests__/simulation.test.ts +++ b/packages/gatekeeper-kit/__tests__/simulation.test.ts @@ -127,6 +127,20 @@ describe("ProvisionalIds", () => { expect(() => ids.requireResolved("~2")).toThrow(/has not been created yet/); }); + it("reports a classified provider id as resolved, since nothing has to bind it", () => { + // The natural `isResolvedReference` spelling for an action set: a dependsOn ref that is + // already a provider id must not read as unresolved and block its apply. + const ids = new ProvisionalIds(makeKv(), { + namespace: "issues:", + isProvisional: id => id.startsWith("~"), + }); + ids.bind("~1", "real-1"); + + expect(ids.isResolved("real-9")).toBe(true); + expect(ids.isResolved("~1")).toBe(true); + expect(ids.isResolved("~2")).toBe(false); + }); + it("can adopt existing unnamespaced provisional keys without migration", () => { const kv = makeKv(); kv.put("seq:provisional", 7); @@ -166,6 +180,22 @@ describe("ProvisionalIds", () => { expect(ids.isResolved("~1")).toBe(false); }); + it("refuses an id a classifierless instance bound to another provisional", () => { + const kv = makeKv(); + // The only writer that can leave such a pair: with no classifier, `bind` cannot tell the + // target apart from a provider ID. + new ProvisionalIds(kv, { namespace: "issues:" }).bind("~1", "~2"); + const ids = new ProvisionalIds(kv, { + namespace: "issues:", + isProvisional: id => id.startsWith("~"), + }); + + // The documented `isResolvedReference` predicate must not release what requireResolved and + // the provider will both refuse. + expect(ids.isResolved("~1")).toBe(false); + expect(() => ids.requireResolved("~1")).toThrow(/bound to ~2/); + }); + it("refuses to retarget a provisional ID an at-least-once retry created twice", () => { // No classifier: the guard is about the stored binding, not the shape of the IDs. const ids = new ProvisionalIds(makeKv(), { namespace: "issues:" }); diff --git a/packages/gatekeeper-kit/__tests__/workerd/conformance.test.ts b/packages/gatekeeper-kit/__tests__/workerd/conformance.test.ts new file mode 100644 index 0000000000..85f908c833 --- /dev/null +++ b/packages/gatekeeper-kit/__tests__/workerd/conformance.test.ts @@ -0,0 +1,468 @@ +/** + * Conformance suite for the kit's assembly. + * + * The other workerd suites test one leaf each. This one drives a gatekeeper built from all of them + * at once, because the contracts that matter to a new consumer are the ones that only appear when + * the pieces are wired together: a fence captured in one module and checked in another, a cursor + * whose authorization outlives the call that made it, an action whose provider outcome is unknown. + */ + +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { RpcStub } from "cloudflare:workers"; +import type { ConformanceAccount, ConformanceResource } from "./conformance/gatekeeper"; +import { + advertised, + FixtureQueue, + observations, + overseer, + provider, + resetProvider, + submissions, +} from "./conformance/gatekeeper"; + +let seq = 0; + +/** A fresh account and resource pair, so no test inherits another's durable state. */ +function bind() { + seq += 1; + const account = env.CONFORMANCE_ACCOUNT.getByName(`account-${seq}`); + const resource = env.CONFORMANCE_RESOURCE.getByName(`resource-${seq}`); + return { account, resource }; +} + +/** Binds the way the overseer hands a session its queue: borrowed for the call, not given away. */ +async function bindResource( + resource: DurableObjectStub, + account: DurableObjectStub, +): Promise { + using queue = new RpcStub(new FixtureQueue()); + await resource.bind(account, queue); +} + +/** Runs a full connect handshake, as a user clicking through the connect page does. */ +async function connect(account: DurableObjectStub): Promise { + const initiation = await account.beginConnect(); + const oauth = await account.beginOAuth(initiation); + expect(await account.completeConnect(oauth!)).toBe(true); +} + +beforeEach(() => { + resetProvider(); + provider.projects.set("project-a", { id: "project-a", name: "Alpha", spaceId: "space-1" }); + provider.projects.set("project-b", { id: "project-b", name: "Beta", spaceId: "space-2" }); +}); + +describe("credentials and connect", () => { + it("completes a handshake and serves credentials without refresh material", async () => { + const { account } = bind(); + await connect(account); + + const read = await account.getCredentials(); + expect(read.creds.accessToken).toMatch(/^user-a-access/); + expect(read.identity).not.toBe(""); + // The account is the only holder: a resource facet must never see this. + expect(read.creds).not.toHaveProperty("refreshToken"); + }); + + it("survives repeated rotation, which a response-shaped record would not", async () => { + // The provider omits unchanged fields and rotates the refresh token, so a consumer that stored + // the response verbatim would lose `scopes` immediately and fail the *second* refresh. + const { account } = bind(); + await connect(account); + const first = await account.getCredentials(); + + for (let round = 0; round < 3; round++) { + await account.reportCredentialsRejected((await account.getCredentials()).identity); + } + + const latest = await account.getCredentials(); + expect(latest.creds.scopes).toEqual(["projects:read", "projects:write"]); + expect(latest.identity).not.toBe(first.identity); + // Each rotation revokes the token it replaced, so this is what proves all three refreshed — + // comparing only the first and last identity passes on one rotation and two no-ops. + expect(provider.revoked.size).toBe(3); + }); + + it("reports a grant the provider revoked as expiry, not as a recycled 401", async () => { + // Only the refresh frame can tell the two apart. Left as an ordinary auth error, the account + // adjudicates "unavailable" and the dead grant stays adoptable as cache authority. + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + provider.controls.grantDead = true; + provider.controls.rejectCredentials = true; + + await expect(async () => { await resource.searchProjects("Alpha"); }) + .rejects.toThrow(/Reconnect the conformance account/); + }); + + it("refuses a completion whose connection was replaced while it exchanged", async () => { + // The real race: the disconnect lands *inside* the token exchange, not before it. Checked + // before the exchange instead of after, this would pass while the window stayed open. + const { account } = bind(); + await connect(account); + const initiation = await account.beginConnect(); + const stale = await account.beginOAuth(initiation); + + // The revoke lands inside the exchange, not before it. Checked before the exchange instead of + // after, this would pass while the window stayed open. + expect(await account.completeConnect(stale!, true)).toBe(false); + expect(await account.isConnected()).toBe(false); + }); + + it("refuses a callback whose nonce a newer attempt replaced", async () => { + const { account } = bind(); + const first = await account.beginConnect(); + const firstOAuth = await account.beginOAuth(first); + // The user starts over; the handshake holds one attempt, so the first is now dead. + const second = await account.beginConnect(); + await account.beginOAuth(second); + + expect(await account.completeConnect(firstOAuth!)).toBe(false); + }); +}); + +describe("observations", () => { + it("excludes a collaborator from the spaces they cannot see", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + provider.access.set("limited", new Set(["space-1"])); + await resource.addObserver("limited", "limited"); + + using cursor = await resource.listProjects(); + expect((await cursor.next())?.length).toBe(2); + + // Both spaces were disclosed and the collaborator holds only one, so they are excluded. + expect(observations[0]?.excludeObservers).toEqual(["limited"]); + }); + + it("excludes a collaborator from a search that covered a space they cannot see", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + provider.access.set("limited", new Set(["space-1"])); + await resource.addObserver("limited", "limited"); + + // Only the space-1 project matches, but the search read space-2 as well: the miss there is + // disclosure too, so naming only the matched space would leak it. + expect((await resource.searchProjects("Alpha")).map(project => project.id)) + .toEqual(["project-a"]); + expect(observations[0]?.excludeObservers).toEqual(["limited"]); + }); + + it("authorizes a zero-result search, which is an existence oracle", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + expect(await resource.searchProjects("nothing-matches")).toEqual([]); + + // Absence is provider data: it must not reach the gadget unrecorded. + expect(observations).toHaveLength(1); + expect(observations[0]?.description).toMatch(/nothing-matches/); + }); + + it("authorizes the terminal answer of a walk that returned nothing", async () => { + const { account, resource } = bind(); + provider.projects.clear(); + await connect(account); + await bindResource(resource, account); + + using cursor = await resource.listProjects(); + expect(await cursor.next()).toBeNull(); + + expect(observations.map(sent => sent.description)) + .toEqual(["Listed projects; there were none."]); + }); + + it("authorizes every page of a walk, including one served from the buffer", async () => { + const { account, resource } = bind(); + for (const index of [1, 2, 3, 4, 5]) { + provider.projects.set(`extra-${index}`, + { id: `extra-${index}`, name: `Extra ${index}`, spaceId: "space-1" }); + } + await connect(account); + await bindResource(resource, account); + + using cursor = await resource.listProjects(); + let pages = 0; + while (await cursor.next() !== null) pages += 1; + + // One observation per returned page, and fewer provider fetches than pages — so at least one + // authorized page was served from the buffer with no fetch behind it. + expect(observations).toHaveLength(pages); + expect(pages).toBeGreaterThan(1); + expect(provider.listCalls).toBeLessThan(pages); + }); + + it("stops a walk whose connection was replaced between pages", async () => { + // A continuation token is provider state scoped to one account. Presenting it under the next + // connection would page through the new principal's projects from the old one's offset. + const { account, resource } = bind(); + for (const index of [1, 2, 3, 4, 5]) { + provider.projects.set(`extra-${index}`, + { id: `extra-${index}`, name: `Extra ${index}`, spaceId: "space-1" }); + } + await connect(account); + await bindResource(resource, account); + + using cursor = await resource.listProjects(); + expect(await cursor.next()).not.toBeNull(); + await account.disconnect(); + await connect(account); + + await expect(async () => { await cursor.next(); }) + .rejects.toThrow(/walk was started under a connection/); + }); + + it("refuses a held page whose connection was replaced before the retry", async () => { + // A refused page is held rather than refetched, so the retry never re-enters the fetch where + // the walk's authority is checked. Without a second check it would disclose the previous + // connection's rows under the new one. + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + using cursor = await resource.listProjects(); + overseer.refuseNext = true; + await expect(async () => { await cursor.next(); }).rejects.toThrow(/overseer refused/); + + await account.disconnect(); + await connect(account); + + await expect(async () => { await cursor.next(); }) + .rejects.toThrow(/walk was started under a connection/); + }); +}); + +describe("actions", () => { + it("applies a create, then its dependent rename against the real provider id", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + const create = await resource.submit("createProject", + { ref: "~new", name: "Gamma", spaceId: "space-1" }); + const rename = await resource.submit("renameProject", + { target: "~new", name: "Gamma Renamed" }); + + await resource.apply(create); + await resource.apply(rename); + + // The provisional reference resolved to whatever the provider minted. + expect([...provider.projects.values()].map(project => project.name)) + .toContain("Gamma Renamed"); + }); + + it("puts every staged action through the approval queue with its rendered description", async () => { + // Without this the whole queue seam is untested: a consumer that stopped calling + // `submitAction` would still allocate a journal record and still apply. + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + const id = await resource.submit("createProject", + { ref: "~queued", name: "Iota", spaceId: "space-1" }); + + expect(submissions).toEqual([[id, { + title: 'Create project "Iota"', + description: "Creates **Iota** in space space-1.", + implementsRevert: false, + autoApprovable: false, + actionKind: { tag: "create-project", label: "Create a project" }, + }]]); + }); + + it("refuses to dispatch a dependent whose reference is still provisional", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + await resource.submit("createProject", + { ref: "~later", name: "Delta", spaceId: "space-1" }); + const rename = await resource.submit("renameProject", + { target: "~later", name: "Delta Renamed" }); + + // Passing "~later" to the provider is what must not happen. + await expect(async () => { await resource.apply(rename); }).rejects.toThrow(/not applied yet/); + }); + + it("records an ambiguous provider outcome without claiming the effect did not land", async () => { + // The create reaches the provider, which commits it and then times out. Marking this + // "not applied" would be a lie, and replaying it would create a second project. + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + provider.controls.timeoutAfterCreate = true; + const create = await resource.submit("createProject", + { ref: "~ghost", name: "Epsilon", spaceId: "space-1" }); + + await expect(async () => { await resource.apply(create); }).rejects.toThrow(/timed out/); + + expect(await resource.record(create)).toMatchObject({ state: "failed", outcome: "unknown" }); + + // Terminal: a second approval is refused before the provider is reached, so the effect that + // did land stays a single one. + provider.controls.timeoutAfterCreate = false; + await expect(async () => { await resource.apply(create); }).rejects.toThrow(/timed out/); + expect([...provider.projects.values()].filter(project => project.name === "Epsilon")) + .toHaveLength(1); + }); + + it("keeps a dependent decidable when its provider's outcome is unknown", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + provider.controls.timeoutAfterCreate = true; + const create = await resource.submit("createProject", + { ref: "~maybe", name: "Zeta", spaceId: "space-1" }); + const rename = await resource.submit("renameProject", + { target: "~maybe", name: "Zeta Renamed" }); + + await expect(async () => { await resource.apply(create); }).rejects.toThrow(/timed out/); + + // The project may exist, so retiring the rename would destroy viable work. + expect((await resource.record(rename))?.state).toBe("pending"); + }); +}); + +describe("assembly", () => { + it("advertises a commit through the gate rather than a raw queue stub", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + await resource.advertiseHead("abc123"); + + expect(advertised).toEqual(["abc123"]); + }); + + it("repartitions the cache when the account reconnects as another principal", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + expect((await resource.searchProjects("Alpha")).map(project => project.id)) + .toEqual(["project-a"]); + + // The provider now answers as someone else, and the account reconnects to them. A cache keyed + // on a last-seen fence would keep serving the previous principal's hit for the whole TTL. + provider.principal = "user-b"; + provider.projects.set("project-c", { id: "project-c", name: "Alpha two", spaceId: "space-9" }); + await account.disconnect(); + await connect(account); + + expect((await resource.searchProjects("Alpha")).map(project => project.id)) + .toEqual(["project-a", "project-c"]); + }); + + it("stops a warm facet reading a grant another facet's rejection buried", async () => { + const { account, resource } = bind(); + const other = env.CONFORMANCE_RESOURCE.getByName(`resource-${seq}-b`); + await connect(account); + await bindResource(resource, account); + await bindResource(other, account); + + // The second facet vouches for the grant and warms its cache under it. + expect((await other.searchProjects("Alpha")).map(project => project.id)).toEqual(["project-a"]); + + provider.controls.grantDead = true; + provider.controls.rejectCredentials = true; + await expect(async () => { await resource.searchProjects("Alpha"); }) + .rejects.toThrow(/Reconnect the conformance account/); + + // The account recorded the death, so the warm facet must refuse rather than serve its hit -- + // nothing about the grant's own hour-long expiry says it is dead. + await expect(async () => { await other.searchProjects("Alpha"); }) + .rejects.toThrow(/credentials have expired/); + + provider.controls.grantDead = false; + provider.controls.rejectCredentials = false; + await account.disconnect(); + await connect(account); + + expect((await other.searchProjects("Alpha")).map(project => project.id)).toEqual(["project-a"]); + }); + + it("keeps a cursor's lease walking after its resource rebinds", async () => { + const { account, resource } = bind(); + for (const index of [1, 2, 3]) { + provider.projects.set(`extra-${index}`, + { id: `extra-${index}`, name: `Extra ${index}`, spaceId: "space-1" }); + } + await connect(account); + await bindResource(resource, account); + + using cursor = await resource.listProjects(); + expect(await cursor.next()).not.toBeNull(); + const authorized = observations.length; + + // Rebinding releases the queue and gate the previous bind made; the cursor's lease owns its + // own dup and must outlive both. + await bindResource(resource, account); + + expect(await cursor.next()).not.toBeNull(); + expect(observations).toHaveLength(authorized + 1); + }); + + it("refreshes and retries a read whose stored access token the provider has rotated", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + // The provider issues a newer token, so the stored one now 401s exactly as a stale one does. + provider.mint(); + + expect((await resource.searchProjects("Alpha")).map(project => project.id)) + .toEqual(["project-a"]); + // The rotating refresh revokes the token it replaced, so this proves the read went through a + // refresh rather than being served by a token the provider should have rejected. + expect(provider.revoked.size).toBe(1); + }); + + it("refuses an action approved under a connection that has since been replaced", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + const staged = await resource.submit("createProject", + { ref: "~fenced", name: "Fenced", spaceId: "space-1" }); + + await account.disconnect(); + await connect(account); + + await expect(async () => { await resource.apply(staged); }) + .rejects.toThrow(/has since been replaced/); + expect((await resource.record(staged))?.state).toBe("failed"); + }); + + it("refuses a reconnect landing after apply's entry check but before the provider call", async () => { + // The entry check passes under connection A and the provider call would run under B. Only the + // handler comparing its own read against the fence closes this; nothing earlier can. + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + const staged = await resource.submit("createProject", + { ref: "~raced", name: "Raced", spaceId: "space-1" }); + + await expect(async () => { await resource.apply(staged, true); }) + .rejects.toThrow(/has since been replaced/); + // The provider was never called, so no project was created under the new connection — and the + // record is terminal, not restored to pending under a fence that can never match again. + expect([...provider.projects.values()].map(project => project.name)).not.toContain("Raced"); + expect((await resource.record(staged))?.state).toBe("failed"); + }); + + it("keeps two journals over one Durable Object from seeing each other", async () => { + const { account, resource } = bind(); + await connect(account); + await bindResource(resource, account); + + const { ids, names } = await resource.isolation(); + + // Each journal issues id 1 and reads back its own payload. Sharing a keyspace would make the + // second allocation collide with the first and both would read the same record. + expect(ids).toEqual([1, 1]); + expect(names).toEqual(["left-project", "right-project"]); + }); +}); diff --git a/packages/gatekeeper-kit/__tests__/workerd/conformance/gatekeeper.ts b/packages/gatekeeper-kit/__tests__/workerd/conformance/gatekeeper.ts new file mode 100644 index 0000000000..c69679475d --- /dev/null +++ b/packages/gatekeeper-kit/__tests__/workerd/conformance/gatekeeper.ts @@ -0,0 +1,567 @@ +/** + * The conformance consumer: a gatekeeper assembled from the kit's leaves against `FakeProvider`. + * + * Its job is to be the first thing that composes them, so a contract that only breaks in assembly + * breaks here rather than in the first real port. Everything a real gatekeeper would own -- grant + * shape, error classification, action presentation, ACL oracle -- is written out rather than + * abstracted, because the point is to show what a consumer must write. + */ + +import { DurableObject, RpcStub, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import type { + ActionDescription, + GitCache, + ObservationDescription, +} from "@gadgets/workshop-shared/gatekeeper"; +import { + ActionApplyError, + ActionOutcomeUnknownError, + ActionJournal, + defineActions, + type TaggedAction, +} from "../../../src/actions"; +import type { ActionFence } from "../../../src/action-journal"; +import { KvTtlCache } from "../../../src/cache"; +import { advanceToOAuth, claimOAuth, putInitiation } from "../../../src/connect-handshake"; +import { + CredentialCoordinator, + CredentialSource, + CredentialsExpiredError, + isConnectionSuperseded, + type CredentialRead, + type RejectionVerdict, +} from "../../../src/credentials"; +import { TokenCursor } from "../../../src/cursors"; +import { ProvisionalIds } from "../../../src/simulation"; +import { ObservationGate, trackedCollectionObservers } from "../../../src/observers"; +import { + FakeProvider, + ProviderAuthError, + ProviderTimeoutError, + type Grant, + type Project, + type PublicGrant, +} from "./provider"; + +/** One provider per test run, reached by both the account and its resources. */ +export const provider = new FakeProvider(); + +/** Every observation the gate sent, in order. */ +export const observations: ObservationDescription[] = []; + +/** Every action staged, as `[id, description]`. */ +export const submissions: [number, ActionDescription][] = []; + +/** Commit ids advertised through the gate's git cache. */ +export const advertised: string[] = []; + +/** Refuses the next observation, as the overseer does on a policy refusal. */ +export const overseer = { refuseNext: false }; + +/** Resets shared state between tests, since these module instances outlive one. */ +export function resetProvider(): void { + provider.controls.rejectCredentials = false; + provider.controls.grantDead = false; + provider.controls.timeoutAfterCreate = false; + provider.principal = "user-a"; + provider.listCalls = 0; + provider.revoked.clear(); + provider.projects.clear(); + provider.access.clear(); + observations.length = 0; + submissions.length = 0; + advertised.length = 0; + overseer.refuseNext = false; +} + +/** Deliberately partial: this consumer only advertises commits. */ +class FixtureGitCache extends RpcTarget { + async advertiseCommit(oid: string): Promise { + advertised.push(oid); + } +} + +/** + * Stands in for the overseer's approval queue. A `WorkerEntrypoint`, not a plain object: a bare + * object's methods cross an RPC boundary as call-scoped stubs that are disposed when that call + * returns, so a gate built from one is dead by its first use. + */ +export class FixtureQueue extends RpcTarget { + async authorizeObservation(description: ObservationDescription): Promise { + if (overseer.refuseNext) { + overseer.refuseNext = false; + throw new Error("the overseer refused this observation"); + } + observations.push(description); + } + + async submitAction(action: number, description: ActionDescription): Promise { + submissions.push([action, description]); + } + + /** The git cache a gatekeeper returning commit ids must advertise through. */ + async getGitCache(): Promise { + return new FixtureGitCache() as unknown as GitCache; + } +} + +/** Actions this gatekeeper can be asked to take. */ +type Actions = { + createProject: { ref: string; name: string; spaceId: string }; + renameProject: { target: string; name: string }; +}; + +/** + * Closes the window apply's entry check cannot: a reconnect landing between that check and the + * provider call. Terminal, and `ActionApplyError` rather than a bare throw: this runs before the + * provider is reached, so no effect landed and retrying under a fence that can never match again + * would leave the action pending for good. + * @param fence The action's captured authority, absent for an unfenced action. + * @param read The credential read this provider call runs under. + */ +function requireActionFence(fence: ActionFence | undefined, read: CredentialRead): void { + if (fence && fence.generation !== read.generation) { + throw new ActionApplyError( + "This action was approved under a connection that has since been replaced. " + + "Reject it and submit it again."); + } +} + +/** + * Refuses a walk whose continuation token belongs to a connection the account has moved past. + * @param opened The read the walk opened under. + * @param read The credential read this page runs under. + */ +function requireWalkFence(opened: CredentialRead, read: CredentialRead): void { + if (opened.generation !== read.generation) { + throw new Error("This walk was started under a connection that has since been replaced."); + } +} + +/** + * What the action handlers may do. Deliberately not the Durable Object: exporting provider + * mutators on the DO would let any stub holder bypass the staged-approval path entirely. + */ +type ProviderHost = { + createProject(name: string, spaceId: string, fence?: ActionFence): Promise; + renameProject(id: string, name: string, fence?: ActionFence): Promise; + refs: ProvisionalIds; +}; + +const actions = defineActions({ + createProject: { + kind: { tag: "create-project", label: "Create a project" }, + delivery: "continue-with-simulation", + // Non-idempotent at the provider, so a lost activation must not replay it. + claimBeforeApply: true, + describe: payload => ({ + title: `Create project "${payload.name}"`, + description: `Creates **${payload.name}** in space ${payload.spaceId}.`, + implementsRevert: false, + }), + provides: payload => [payload.ref], + apply: async (payload, host, { fence }) => { + const id = await host.createProject(payload.name, payload.spaceId, fence); + host.refs.bind(payload.ref, id); + }, + }, + renameProject: { + kind: { tag: "rename-project", label: "Rename a project" }, + delivery: "continue-with-simulation", + describe: payload => ({ + title: `Rename ${payload.target}`, + description: `Renames ${payload.target} to **${payload.name}**.`, + // The kit cannot check this claim, so the fixture must not make one it has no handler for. + implementsRevert: false, + }), + dependsOn: payload => [payload.target], + apply: async (payload, host, { fence }) => { + // Resolved, never defaulted: apply already refused an unresolved reference, so a + // provisional string reaching the provider would be a kit bug rather than a fallback. + await host.renameProject(host.refs.requireResolved(payload.target), payload.name, fence); + }, + }, +}, { + // Both kinds name a project in one provider account, so neither means anything under another + // connection. Declaring it here is what makes `submit` refuse a call that forgot the fence. + fence: "authority", + isResolvedReference: (host, ref) => host.refs.isResolved(ref), +}); + +/** + * The account Durable Object. Owns credentials and the connect handshake, and is the only holder of + * refresh material. + */ +export class ConformanceAccount extends DurableObject { + readonly #creds = new CredentialCoordinator(this.ctx.storage.kv, { + expiresAt: grant => grant.expiresAt, + // Rotation is per-token here, so revoking a fenced-out mint cannot kill the winner. + discardMint: grant => void provider.revoked.add(grant.refreshToken), + vendorId: "conformance", + }); + + /** @returns The nonce a connect link carries. */ + beginConnect(): string { + const nonce = crypto.randomUUID(); + putInitiation(this.ctx.storage.kv, nonce, Date.now()); + return nonce; + } + + /** + * Advances to the provider redirect, capturing the connection this attempt started under. + * @param initiationNonce Nonce from the connect link. + * @returns The OAuth nonce, or `null` when the attempt is stale. + */ + beginOAuth(initiationNonce: string): string | null { + return advanceToOAuth(this.ctx.storage.kv, initiationNonce, Date.now(), + { startedUnder: this.#creds.connectionGeneration() }); + } + + /** + * Completes the callback. The claim is irrevocable, so the exchange happens after it and the + * write is fenced on the generation the attempt started under. + * @param oauthNonce Nonce the provider returned. + * @returns Whether the connection was stored. + */ + async completeConnect(oauthNonce: string, revokeDuringExchange = false): Promise { + const claim = claimOAuth<{ startedUnder: string }>(this.ctx.storage.kv, oauthNonce, Date.now()); + if (claim === null) return false; + // The exchange is the window `claimOAuth` cannot cover; `ifGeneration` fences it. + const grant = await Promise.resolve(provider.mint()); + // A revoke landing inside that window, which is the case the fence exists for. + if (revokeDuringExchange) this.#creds.clear(); + try { + this.#creds.connect(grant, { ifGeneration: claim.startedUnder }); + } catch (error) { + if (!isConnectionSuperseded(error)) throw error; + // The mint was never stored, so disposing of it is ours to do. Safe here because this + // provider revokes per token; a grant-wide revocation would kill the winning connection. + provider.revoked.add(grant.refreshToken); + return false; + } + return true; + } + + /** @returns Whether credentials are stored, so a test can see which write won. */ + isConnected(): boolean { + return this.#creds.stored() !== undefined; + } + + /** Disconnects, as a user revoke does. */ + disconnect(): void { + this.#creds.clear(); + } + + /** @returns The credential triple, with refresh material projected out. */ + async getCredentials(): Promise<{ creds: PublicGrant } & CredentialRead> { + const { creds, identity, generation } = await this.#creds.snapshot(grant => this.#refresh(grant)); + const { refreshToken: _refreshToken, ...publicGrant } = creds; + return { creds: publicGrant, identity, generation }; + } + + /** + * Adjudicates a rejection the resource saw. + * @param identity Credential identity that was rejected. + * @returns The account's verdict. + */ + reportCredentialsRejected(identity: string): Promise { + return this.#creds.adjudicateRejection(identity, { + refresh: grant => this.#refresh(grant), + notify: async () => {}, + }); + } + + /** + * Refreshes at the provider. The response omits unchanged fields, so the stored record is merged + * rather than replaced -- without this the *next* refresh fails. + * @param current The stored grant. + * @returns The complete replacement record. + */ + async #refresh(current: Grant): Promise { + let response; + try { + response = await Promise.resolve(provider.refresh(current)); + } catch (error) { + // The token endpoint refusing the refresh token is the grant's death, and only this frame + // can say so: to every layer above it is an ordinary 401 from an unknown cause. + if (error instanceof ProviderAuthError && /invalid_grant/.test(error.message)) { + throw new CredentialsExpiredError("This connection was revoked at the provider.", + { cause: error }); + } + throw error; + } + return { ...current, ...response, refreshToken: response.refreshToken ?? current.refreshToken }; + } +} + +/** + * The collaborator ACL oracle as a capability, not a value: a `WorkerEntrypoint` behind + * `ctx.exports` is what the overseer hands a gatekeeper, and the only kind of stub Durable Object + * storage will persist. + */ +export class ConformanceVerifier extends WorkerEntrypoint { + async hasSpaces(spaceIds: readonly string[]): Promise { + return spaceIds.map(spaceId => provider.hasAccess(this.ctx.props.user, spaceId)); + } +} + +type SpaceVerifier = { hasSpaces(spaceIds: readonly string[]): Promise }; + +/** What the conformance suite drives; a real gatekeeper would expose this over RPC. */ +export class ConformanceResource extends DurableObject { + #account?: DurableObjectStub; + + readonly #creds = new CredentialSource({ + account: () => this.#requireAccount(), + isAuthError: error => error instanceof ProviderAuthError, + expiredMessage: "Reconnect the conformance account.", + vendorId: "conformance", + }); + + readonly #observers = trackedCollectionObservers({ + kv: this.ctx.storage.kv, + hasCollectionAccess: (verifier, spaceIds) => verifier.hasSpaces(spaceIds), + }); + + // Named, so it cannot collide with another cache over this same storage. + readonly #cache = KvTtlCache.partitionedBy(this.ctx.storage.kv, this.#creds, { name: "projects" }); + + readonly #journal = new ActionJournal>(this.ctx.storage.kv, { + namespace: "projects", + }); + + readonly #refs = new ProvisionalIds(this.ctx.storage.kv, { + namespace: "projects", + isProvisional: ref => ref.startsWith("~"), + }); + + readonly #host: ProviderHost = { + createProject: (name, spaceId, fence) => this.#createProject(name, spaceId, fence), + renameProject: (id, name, fence) => this.#renameProject(id, name, fence), + refs: this.#refs, + }; + + #gate?: ObservationGate; + #queue?: RpcStub; + #reconnectMidApply = false; + + /** + * Binds the account this resource answers for and the queue its session stages through. + * @param account The account Durable Object. + * @param queue The overseer's approval queue, borrowed for this call only. + */ + bind(account: DurableObjectStub, queue: RpcStub): void { + this.#account = account; + // Two owners, as a session has: its own queue for staging actions, and a gate over a second + // dup. Both come from the borrowed argument, which the caller drops when this call returns. + // Rebinding releases the pair the previous bind made; leases outlive it. + this.#gate?.[Symbol.dispose](); + this.#queue?.[Symbol.dispose](); + this.#queue = queue.dup(); + this.#gate = new ObservationGate(queue.dup(), this.#observers); + } + + #requireAccount(): DurableObjectStub { + if (!this.#account) throw new Error("resource is not bound"); + return this.#account; + } + + /** Replaces the connection underneath an in-flight operation. */ + async #reconnect(): Promise { + this.#reconnectMidApply = false; + const account = this.#requireAccount(); + await account.disconnect(); + await account.completeConnect(await account.beginOAuth(await account.beginConnect()) ?? ""); + } + + #requireGate(): ObservationGate { + if (!this.#gate) throw new Error("resource is not bound"); + return this.#gate; + } + + #requireQueue(): RpcStub { + if (!this.#queue) throw new Error("resource is not bound"); + return this.#queue; + } + + /** + * Admits a collaborator, which verifies their access to every space read so far. + * @param id Collaborator id. + * @param user Provider-side user the collaborator maps to. + */ + addObserver(id: string, user: string): Promise { + return this.#observers.addObserver(id, this.ctx.exports.ConformanceVerifier({ props: { user } })); + } + + /** @returns Every project, paged, with each page authorized before it is returned. */ + async listProjects(): Promise> { + // Pinned to the connection the walk opened under: a continuation token is provider state + // scoped to one account, so presenting it under the next one mixes or skips rows. Read before + // leasing, so a disconnected account throws with nothing acquired. + const opened = await this.#creds.read(); + // The cursor is returned to the caller and walked later, so it takes its own lease rather than + // borrowing the session's stub, and releases it when the walk is dropped. + const walk = this.#requireGate().lease(); + return new TokenCursor({ + dispose: () => walk[Symbol.dispose](), + pageSize: 2, + // Wider than the local page, so a walk serves one page from the buffer with no fetch. + remotePageSize: 4, + fetchPage: (token, perPage) => this.#creds.run( + async (creds, read) => { + requireWalkFence(opened, read); + return provider.listProjects(creds, token, perPage); + }, + { replayable: true }), + authorizePage: async (projects, { terminal }) => { + // Re-checked here, not only in `fetchPage`: a refused page is held and re-offered without + // refetching, so this is the only check the retry path runs. + requireWalkFence(opened, await this.#creds.read()); + await (projects.length === 0 + ? walk.authorize( + { + title: "Projects", + description: terminal ? "Listed projects; there were none." : "Scanned an empty window.", + }, + { kind: "baseline" }) + : walk.authorize( + { title: "Projects", description: `Read ${projects.length} projects.` }, + { kind: "collections", ids: [...new Set(projects.map(project => project.spaceId))] })); + }, + }); + } + + /** + * Searches projects, cached under the live connection fence. + * @param query Name substring. + * @returns Matching projects. + */ + async searchProjects(query: string): Promise { + const { matches, spaces } = await this.#cache.cached(`search:${query}`, 60_000, + () => this.#creds.run(async creds => provider.searchProjects(creds, query), + { replayable: true })); + // Every space searched, not just the ones that matched: a miss discloses absence in each of + // them, so an observer excluded from one must not learn that. + await this.#requireGate().authorize( + { title: "Search", description: `Searched projects for "${query}".` }, + spaces.length === 0 ? { kind: "baseline" } : { kind: "collections", ids: spaces }); + return matches; + } + + /** + * Advertises a commit through the gate, the way a git-backed gatekeeper must. Reaching the cache + * through the gate is what keeps the raw queue stub out of session code. + * @param oid Commit id to advertise. + */ + async advertiseHead(oid: string): Promise { + using cache = await this.#requireGate().getGitCache(); + await cache.advertiseCommit(oid); + } + + /** + * Allocates one action in each of two journals over this same storage. + * @returns Each journal's allocated id and what the other can see of it. + */ + isolation(): { ids: [number, number]; names: [string?, string?] } { + const journalFor = (namespace: string) => + new ActionJournal>(this.ctx.storage.kv, { namespace }); + const staged = (name: string) => + ({ kind: "createProject", payload: { ref: `~${name}`, name, spaceId: "s" } }) as + TaggedAction; + const left = journalFor("left"); + const right = journalFor("right"); + const leftId = left.allocate(staged("left-project")); + const rightId = right.allocate(staged("right-project")); + // Reading each id back through its own journal: ids collide, so only the payload distinguishes + // whose record it is. A shared keyspace would have the second write clobber the first. + const leftRecord = left.get(leftId)?.action; + const rightRecord = right.get(rightId)?.action; + return { + ids: [leftId, rightId], + names: [ + leftRecord?.kind === "createProject" ? leftRecord.payload.name : undefined, + rightRecord?.kind === "createProject" ? rightRecord.payload.name : undefined, + ], + }; + } + + /** + * Creates a project at the provider, classifying an ambiguous outcome honestly. + * @param name Project name. + * @param spaceId Owning space. + * @returns The new project id. + */ + async #createProject(name: string, spaceId: string, fence?: ActionFence): Promise { + // Apply's entry check has already passed, so a reconnect landing before this fetch is + // invisible to it -- the operation would run under the new connection. + if (this.#reconnectMidApply) await this.#reconnect(); + return this.#creds.run(async (creds, read) => { + requireActionFence(fence, read); + try { + return provider.createProject(creds, name, spaceId); + } catch (error) { + // The provider was reached, so the effect may have landed: never say it did not. + if (error instanceof ProviderTimeoutError) { + throw new ActionOutcomeUnknownError( + "The provider timed out creating this project; check before submitting it again."); + } + throw error; + } + }); + } + + /** + * Renames a project. + * @param id Provider project id. + * @param name New name. + */ + async #renameProject(id: string, name: string, fence?: ActionFence): Promise { + await this.#creds.run(async (creds, read) => { + requireActionFence(fence, read); + provider.renameProject(creds, id, name); + }); + } + + /** + * Stages an action, the way a session method does. The bound set stays inside the resource: a + * gatekeeper exposes RPC methods, not its journal or its queue stub. + * @param kind Declared action kind. + * @param payload Action payload. + * @returns The staged action id. + */ + async submit(kind: K, payload: Actions[K]): Promise { + // Both kinds are declared connection-fenced, so the set refuses this call without a fence. + // Staged inside a credentialed operation, so the fence is that operation's own read rather + // than a second one a reconnect could land in front of. + return this.#creds.run((_creds, read) => actions.bind(this.#journal, this.#host) + .submit(this.#requireQueue(), kind, payload, { fence: read })); + } + + /** + * Applies a staged action. + * @param id Action id. + */ + async apply(id: number, reconnectMidApply = false): Promise { + const { generation } = await this.#creds.read(); + this.#reconnectMidApply = reconnectMidApply; + try { + await actions.bind(this.#journal, this.#host).apply(id, { generation }); + } finally { + this.#reconnectMidApply = false; + } + } + + /** + * Reads one journal record's state, flattened so it can cross the RPC boundary. + * @param id Action id. + * @returns The record's state and outcome classification, or `undefined` when it is gone. + */ + record(id: number): { state: string; outcome?: string; error?: string } | undefined { + const stored = this.#journal.get(id); + return stored && { + state: stored.state, + ...(stored.state === "failed" && stored.outcome ? { outcome: stored.outcome } : {}), + ...(stored.state === "failed" ? { error: stored.error } : {}), + }; + } +} diff --git a/packages/gatekeeper-kit/__tests__/workerd/conformance/provider.ts b/packages/gatekeeper-kit/__tests__/workerd/conformance/provider.ts new file mode 100644 index 0000000000..d61f32d80b --- /dev/null +++ b/packages/gatekeeper-kit/__tests__/workerd/conformance/provider.ts @@ -0,0 +1,170 @@ +/** + * A fake provider for the conformance consumer: enough of a real API's shape to exercise every kit + * contract, plus the failure modes a real provider will not reproduce on demand. + */ + +/** Thrown for a rejected credential, the way a provider signals 401. */ +export class ProviderAuthError extends Error {} + +/** Thrown when the provider was reached but the outcome is unknowable -- a timeout. */ +export class ProviderTimeoutError extends Error {} + +/** The stored grant. `refreshToken` rotates, so a lost merge breaks the *next* refresh. */ +export type Grant = { + accessToken: string; + refreshToken: string; + scopes: readonly string[]; + expiresAt: number; +}; + +/** What a resource facet may see: refresh material never crosses the account RPC boundary. */ +export type PublicGrant = Omit; + +export type Project = { id: string; name: string; spaceId: string }; + +/** Controls the failure modes a conformance run needs to force. */ +export type ProviderControls = { + /** Rejects every call with the auth error, as a revoked grant does. */ + rejectCredentials?: boolean; + /** Fails refresh with a dead-grant signal rather than issuing a new token. */ + grantDead?: boolean; + /** Creates the project, then times out before returning -- the ambiguous outcome. */ + timeoutAfterCreate?: boolean; +}; + +/** + * In-memory provider. One instance per test, shared by the account and its resources the way a + * real provider's servers are. + */ +export class FakeProvider { + readonly controls: ProviderControls = {}; + /** Refresh tokens the provider will no longer honour, by rotation or explicit revocation. */ + readonly revoked = new Set(); + /** Every project the provider holds, by id. */ + readonly projects = new Map(); + /** Per-user visibility, so a collaborator can legitimately lack access to one space. */ + readonly access = new Map>(); + /** Provider page fetches, so a test can prove a page came from the cursor's buffer. */ + listCalls = 0; + /** The principal a fresh authorization belongs to; reassign it to reconnect as someone else. */ + principal = "user-a"; + #issued = 0; + #created = 0; + + /** + * Refreshes a grant. The response omits everything that did not change, which is what makes a + * merge mandatory in the consumer's refresh callback. + * @param current The grant being refreshed. + * @returns Only the fields the provider considers changed. + */ + refresh(current: Grant): { accessToken: string; expiresAt: number; refreshToken?: string } { + if (this.controls.grantDead || this.revoked.has(current.refreshToken)) { + throw new ProviderAuthError("invalid_grant"); + } + const next = this.mint(); + // Rotating: the old refresh token dies with this call. + this.revoked.add(current.refreshToken); + return { + accessToken: next.accessToken, + expiresAt: next.expiresAt, + refreshToken: next.refreshToken, + }; + } + + /** @returns A newly issued grant, as both the first code exchange and a refresh produce. */ + mint(): Grant { + this.#issued += 1; + return { + accessToken: `${this.principal}-access-${this.#issued}`, + refreshToken: `${this.principal}-refresh-${this.#issued}`, + scopes: ["projects:read", "projects:write"], + expiresAt: Date.now() + 3_600_000, + }; + } + + #check(grant: PublicGrant): void { + if (this.controls.rejectCredentials) throw new ProviderAuthError("401 unauthorized"); + if (grant.accessToken !== `${this.principal}-access-${this.#issued}`) { + throw new ProviderAuthError("401 token belongs to another principal"); + } + } + + /** + * Pages projects. Filters nothing, so the caller's `retain` decides visibility. + * @param grant Current credentials. + * @param token Continuation token. + * @param perPage Requested page size. + * @returns One page and the next token. + */ + listProjects(grant: PublicGrant, token: string | undefined, perPage: number): { + items: Project[]; + nextToken?: string; + } { + this.#check(grant); + this.listCalls += 1; + const all = [...this.projects.values()]; + const start = token === undefined ? 0 : Number(token); + const items = all.slice(start, start + perPage); + const next = start + perPage; + return next < all.length ? { items, nextToken: String(next) } : { items }; + } + + /** + * Answers whether a project matches, across every space the account can see. + * @param grant Current credentials. + * @param query Name substring. + * @returns The matches, and every space the search covered — the scope an observation must + * name, since a miss discloses absence in each of them. + */ + searchProjects(grant: PublicGrant, query: string): { matches: Project[]; spaces: string[] } { + this.#check(grant); + const all = [...this.projects.values()]; + return { + matches: all.filter(project => project.name.includes(query)), + spaces: [...new Set(all.map(project => project.spaceId))], + }; + } + + /** + * Creates a project. Non-idempotent: a retry makes a second one, which is what `claimBeforeApply` + * and the unknown outcome exist to prevent. + * @param grant Current credentials. + * @param name Project name. + * @param spaceId Owning space. + * @returns The new project id. + */ + createProject(grant: PublicGrant, name: string, spaceId: string): string { + this.#check(grant); + this.#created += 1; + const id = `project-${this.#created}`; + this.projects.set(id, { id, name, spaceId }); + if (this.controls.timeoutAfterCreate) { + // The effect landed and the caller will never learn it. + throw new ProviderTimeoutError("gateway timeout"); + } + return id; + } + + /** + * Renames an existing project, the dependent half of a provisional reference. + * @param grant Current credentials. + * @param id Project id. + * @param name New name. + */ + renameProject(grant: PublicGrant, id: string, name: string): void { + this.#check(grant); + const project = this.projects.get(id); + if (!project) throw new Error(`no such project ${id}`); + this.projects.set(id, { ...project, name }); + } + + /** + * Whether a collaborator may see a space. + * @param user Collaborator id. + * @param spaceId Space id. + * @returns Whether access is granted. + */ + hasAccess(user: string, spaceId: string): boolean { + return this.access.get(user)?.has(spaceId) === true; + } +} diff --git a/packages/gatekeeper-kit/__tests__/workerd/cursors.test.ts b/packages/gatekeeper-kit/__tests__/workerd/cursors.test.ts index 61dfd3bc5b..e7048c79d3 100644 --- a/packages/gatekeeper-kit/__tests__/workerd/cursors.test.ts +++ b/packages/gatekeeper-kit/__tests__/workerd/cursors.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import type { + GatekeeperUserVerifier, + GitCache, + ObservationAuthorizer, + ObservationDescription, +} from "@gadgets/workshop-shared/gatekeeper"; +import { RpcStub, RpcTarget } from "cloudflare:workers"; import { ArrayCursor, OffsetCursor, @@ -6,6 +13,8 @@ import { TokenCursor, type TokenPage, } from "../../src/cursors"; +import { ObservationGate, trackedCollectionObservers } from "../../src/observers"; +import { fakeKv } from "../fake-kv"; type Issue = { id: number; open: boolean }; @@ -15,6 +24,36 @@ function pagedApi(items: Issue[]) { items.slice((page - 1) * perPage, page * perPage)); } +/** A real authorizer, so a gate built from its stub is type-checked rather than cast into place. */ +class TestAuthorizer extends RpcTarget implements ObservationAuthorizer { + readonly #seen: string[]; + + constructor(seen: string[]) { + super(); + this.#seen = seen; + } + + async authorizeObservation(description: ObservationDescription): Promise { + if (description.excludeObservers?.length) { + throw new Error(`cannot hide from ${description.excludeObservers.join(", ")}`); + } + this.#seen.push(description.description); + } + + async getGitCache(): Promise { + throw new Error("Git cache is not used in this test."); + } +} + +/** + * A stub over a fresh `TestAuthorizer` and the descriptions it accepted. Annotated, never cast: a + * gate demanding the whole approval queue would fail to compile against it. + */ +function makeAuthorizer(): { authorizer: RpcStub; seen: string[] } { + const seen: string[] = []; + return { authorizer: new RpcStub(new TestAuthorizer(seen)), seen }; +} + /** Serves a scripted sequence of token pages; past the end the provider reports exhaustion. */ function tokenApi(pages: TokenPage[]) { let index = 0; @@ -22,6 +61,9 @@ function tokenApi(pages: TokenPage[]) { pages[index++] ?? { items: [] }); } +// Cursors must authorize the exact page they return; tests that assert on it pass their own. +const authorizePage = async () => {}; + const ids = (page: Issue[] | null) => page?.map(issue => issue.id); describe("ArrayCursor", () => { @@ -38,15 +80,16 @@ describe("ArrayCursor", () => { }); it("rejects a page size that would never terminate", () => { - expect(() => new ArrayCursor([1], 0)).toThrow(/positive integer/); - expect(() => new ArrayCursor([1], 1.5)).toThrow(/positive integer/); + expect(() => new ArrayCursor([1], 0)).toThrow(/positive safe integer/); + expect(() => new ArrayCursor([1], 1.5)).toThrow(/positive safe integer/); }); }); describe("PageNumberCursor", () => { it("fetches only the provider pages a page of results needs", async () => { const fetchPage = pagedApi([1, 2, 3, 4, 5].map(id => ({ id, open: true }))); - const cursor = new PageNumberCursor({ fetchPage, pageSize: 2, remotePageSize: 2 }); + const cursor = new PageNumberCursor( + { fetchPage, authorizePage, pageSize: 2, remotePageSize: 2 }); expect((await cursor.next())?.map(issue => issue.id)).toEqual([1, 2]); expect(fetchPage).toHaveBeenCalledOnce(); @@ -59,7 +102,8 @@ describe("PageNumberCursor", () => { it("serializes concurrent callers instead of duplicating and skipping pages", async () => { const items = [1, 2, 3, 4, 5, 6].map(id => ({ id, open: true })); const fetchPage = pagedApi(items); - const cursor = new PageNumberCursor({ fetchPage, pageSize: 2, remotePageSize: 2 }); + const cursor = new PageNumberCursor( + { fetchPage, authorizePage, pageSize: 2, remotePageSize: 2 }); // A gadget can pipeline these; the provider page counter must not be read twice before it moves. const pages = await Promise.all([cursor.next(), cursor.next(), cursor.next()]); @@ -73,7 +117,8 @@ describe("PageNumberCursor", () => { const items = Array.from({ length: 45 }, (_, index) => ({ id: index + 1, open: true })); // Answers 20 to a request for 100, as Cloudflare's own /accounts endpoint does. const fetchPage = vi.fn(async (page: number) => items.slice((page - 1) * 20, page * 20)); - const cursor = new PageNumberCursor({ fetchPage, pageSize: 100, remotePageSize: 100 }); + const cursor = new PageNumberCursor( + { fetchPage, authorizePage, pageSize: 100, remotePageSize: 100 }); // Stopping at the first short page would have returned only the first 20. expect((await cursor.next())?.length).toBe(45); @@ -88,6 +133,7 @@ describe("PageNumberCursor", () => { if (++attempt === 1) throw new Error("provider 503"); return pages[page - 1] ?? []; }, + authorizePage, pageSize: 1, remotePageSize: 1, }); @@ -108,6 +154,7 @@ describe("PageNumberCursor", () => { if (++attempt === 1) throw new Error("authorization unavailable"); return items; }, + authorizePage, pageSize: 1, remotePageSize: 1, }); @@ -120,13 +167,15 @@ describe("PageNumberCursor", () => { it("exposes no paging method a stub holder could call", () => { // capnweb resolves string paths only; reached by name it would skip the queue. - const cursor = new PageNumberCursor({ fetchPage: async () => [], pageSize: 1 }); + const cursor = new PageNumberCursor( + { fetchPage: async () => [], authorizePage, pageSize: 1 }); expect((cursor as unknown as Record).loadMore).toBeUndefined(); }); it("reports the end rather than an error when the provider is simply empty", async () => { - const cursor = new PageNumberCursor({ fetchPage: async () => [], pageSize: 2 }); + const cursor = new PageNumberCursor( + { fetchPage: async () => [], authorizePage, pageSize: 2 }); expect(await cursor.next()).toBeNull(); }); @@ -137,6 +186,7 @@ describe("PageNumberCursor", () => { const cursor = new PageNumberCursor({ fetchPage: async page => pages[page - 1] ?? [], retain: items => items.filter(issue => issue.open), + authorizePage, pageSize: 2, remotePageSize: 2, }); @@ -148,7 +198,7 @@ describe("PageNumberCursor", () => { it("bounds one call rather than walking a whole history of dropped pages", async () => { const fetchPage = vi.fn(async () => [{ id: 1, open: false }]); const cursor = new PageNumberCursor( - { fetchPage, retain: () => [], pageSize: 2, remotePageSize: 1 }); + { fetchPage, retain: () => [], authorizePage, pageSize: 2, remotePageSize: 1 }); // `[]` invites another call, where null would claim the list had ended. expect(await cursor.next()).toEqual([]); @@ -162,6 +212,7 @@ describe("PageNumberCursor", () => { const cursor = new PageNumberCursor({ fetchPage, retain: items => items.filter(issue => issue.open), + authorizePage, pageSize: 100, remotePageSize: 1, }); @@ -174,11 +225,25 @@ describe("PageNumberCursor", () => { it("rejects page sizes that would never terminate", () => { const fetchPage = pagedApi([]); - expect(() => new PageNumberCursor({ fetchPage, pageSize: 0 })).toThrow(/positive integer/); - expect(() => new PageNumberCursor({ fetchPage, pageSize: 2, remotePageSize: 0 })) - .toThrow(/positive integer/); - expect(() => new PageNumberCursor({ fetchPage, pageSize: 2.5 })) - .toThrow(/positive integer/); + expect(() => new PageNumberCursor( + { fetchPage, authorizePage, pageSize: 0 })).toThrow(/positive safe integer/); + expect(() => new PageNumberCursor( + { fetchPage, authorizePage, pageSize: 2, remotePageSize: 0 })) + .toThrow(/positive safe integer/); + expect(() => new PageNumberCursor({ fetchPage, authorizePage, pageSize: 2.5 })) + .toThrow(/positive safe integer/); + }); + + it("releases what the caller acquired when it rejects the page size", () => { + // The documented pattern leases a gate *before* constructing the cursor, so a throw that + // skipped `dispose` would leak the duplicated stub on every rejected call. + const dispose = vi.fn(); + + expect(() => new PageNumberCursor( + { fetchPage: pagedApi([]), authorizePage, pageSize: 0, dispose })) + .toThrow(/positive safe integer/); + + expect(dispose).toHaveBeenCalledOnce(); }); }); @@ -188,7 +253,8 @@ describe("OffsetCursor", () => { // Answers 20 to a request for 100, as jira's silent `maxResults` clamp does. Page arithmetic // over this shape would request offsets 0, 100, ... and lose rows 20-99 without an error. const fetchPage = vi.fn(async (offset: number) => items.slice(offset, offset + 20)); - const cursor = new OffsetCursor({ fetchPage, pageSize: 100, remotePageSize: 100 }); + const cursor = new OffsetCursor( + { fetchPage, authorizePage, pageSize: 100, remotePageSize: 100 }); expect((await cursor.next())?.length).toBe(45); expect(await cursor.next()).toBeNull(); @@ -202,6 +268,7 @@ describe("OffsetCursor", () => { const cursor = new OffsetCursor({ fetchPage, retain: page => page.filter(issue => issue.open), + authorizePage, pageSize: 10, remotePageSize: 2, }); @@ -220,6 +287,7 @@ describe("OffsetCursor", () => { if (++attempt === 1) throw new Error("provider 503"); return items.slice(offset, offset + 1); }, + authorizePage, pageSize: 1, remotePageSize: 1, }); @@ -240,7 +308,8 @@ describe("TokenCursor", () => { { items: [{ id: 3, open: true }], nextToken: "" }, { items: [{ id: 4, open: true }] }, ]); - const cursor = new TokenCursor({ fetchPage, pageSize: 10, remotePageSize: 25 }); + const cursor = new TokenCursor( + { fetchPage, authorizePage, pageSize: 10, remotePageSize: 25 }); expect(ids(await cursor.next())).toEqual([1, 2, 3, 4]); expect(await cursor.next()).toBeNull(); @@ -255,7 +324,7 @@ describe("TokenCursor", () => { ...Array.from({ length: 12 }, (_, index) => ({ items: [], nextToken: `w${index}` })), { items: [{ id: 1, open: true }] }, ]); - const cursor = new TokenCursor({ fetchPage, pageSize: 2 }); + const cursor = new TokenCursor({ fetchPage, authorizePage, pageSize: 2 }); // `[]` is a legal non-terminal page: only `null` ends a cursor, so the walk survives the cap. expect(await cursor.next()).toEqual([]); @@ -274,6 +343,7 @@ describe("TokenCursor", () => { if (++attempt === 2) throw new Error("provider 503"); return { items: [{ id: attempt, open: true }], nextToken: attempt < 3 ? "t2" : undefined }; }, + authorizePage, pageSize: 1, }); @@ -288,7 +358,7 @@ describe("TokenCursor", () => { token === undefined ? { items: [{ id: 1, open: true }], nextToken: "same" } : { items: [{ id: 2, open: true }], nextToken: token }); - const cursor = new TokenCursor({ fetchPage, pageSize: 10 }); + const cursor = new TokenCursor({ fetchPage, authorizePage, pageSize: 10 }); await expect(cursor.next()).rejects.toThrow(/same continuation token/); await expect(cursor.next()).rejects.toThrow(/same continuation token/); @@ -301,7 +371,8 @@ describe("TokenCursor", () => { { items: [{ id: 3, open: true }, { id: 4, open: true }], nextToken: "b" }, { items: [{ id: 5, open: true }] }, ]); - const cursor = new TokenCursor({ fetchPage, pageSize: 2, remotePageSize: 2 }); + const cursor = new TokenCursor( + { fetchPage, authorizePage, pageSize: 2, remotePageSize: 2 }); const pages = await Promise.all([cursor.next(), cursor.next(), cursor.next()]); @@ -309,10 +380,279 @@ describe("TokenCursor", () => { expect(fetchPage.mock.calls.map(([token]) => token)).toEqual([undefined, "a", "b"]); }); + it("releases what the fetch callback owns, once, however often it is disposed", async () => { + // A callback that duplicates a stub for the walk has nowhere else to release it: the cursor + // stub's disposal is the only signal that the walk is over. + const dispose = vi.fn(); + const cursor = new TokenCursor({ + fetchPage: tokenApi([{ items: [{ id: 1, open: true }] }]), + authorizePage, + pageSize: 1, + dispose, + }); + expect(ids(await cursor.next())).toEqual([1]); + + cursor[Symbol.dispose](); + cursor[Symbol.dispose](); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("authorizes the exact page it returns, including one served from the buffer", async () => { + const fetchPage = tokenApi([ + { items: [1, 2, 3, 4, 5].map(id => ({ id, open: true })) }, + ]); + const authorized = vi.fn(async () => {}); + const cursor = new TokenCursor( + { fetchPage, authorizePage: authorized, pageSize: 2, remotePageSize: 5 }); + + expect(ids(await cursor.next())).toEqual([1, 2]); + expect(ids(await cursor.next())).toEqual([3, 4]); + + // The second page came out of the buffer with no provider call, and was still authorized. + expect(fetchPage).toHaveBeenCalledOnce(); + expect(authorized.mock.calls).toEqual([ + [[{ id: 1, open: true }, { id: 2, open: true }], { terminal: false }], + [[{ id: 3, open: true }, { id: 4, open: true }], { terminal: false }], + ]); + }); + + it("re-offers a refused page rather than dropping it or re-fetching", async () => { + const fetchPage = tokenApi([ + { items: [1, 2, 3, 4, 5].map(id => ({ id, open: true })) }, + ]); + let call = 0; + const cursor = new TokenCursor({ + fetchPage, + authorizePage: async () => { + if (++call === 2) throw new Error("authorization unavailable"); + }, + pageSize: 2, + remotePageSize: 5, + }); + + expect(ids(await cursor.next())).toEqual([1, 2]); + await expect(cursor.next()).rejects.toThrow("authorization unavailable"); + // The same items, and the provider position never rewound, so it must not be asked again. + expect(ids(await cursor.next())).toEqual([3, 4]); + expect(fetchPage).toHaveBeenCalledOnce(); + }); + + it("re-offers a capped page unchanged, rather than growing what was refused", async () => { + // A heavily filtered provider caps the window before the page is full. Refilling on retry + // would hand the approver a larger page than the one they just refused. + const fetchPage = vi.fn(async (page: number) => [{ id: page, open: page % 3 === 0 }]); + const offered: number[][] = []; + let call = 0; + const cursor = new PageNumberCursor({ + fetchPage, + retain: rows => rows.filter(issue => issue.open), + pageSize: 100, + remotePageSize: 1, + authorizePage: async items => { + offered.push(items.map(issue => issue.id)); + if (++call === 1) throw new Error("authorization unavailable"); + }, + }); + + await expect(cursor.next()).rejects.toThrow("authorization unavailable"); + expect(ids(await cursor.next())).toEqual([3, 6, 9]); + + expect(offered).toEqual([[3, 6, 9], [3, 6, 9]]); + // The held page is served without asking the provider again. + expect(fetchPage).toHaveBeenCalledTimes(10); + }); + + it("authorizes a spent fetch window, which discloses that the window held nothing", async () => { + const authorized = vi.fn(async () => {}); + const cursor = new TokenCursor({ + fetchPage: tokenApi( + Array.from({ length: 12 }, (_, index) => ({ items: [], nextToken: `w${index}` }))), + authorizePage: authorized, + pageSize: 2, + }); + + // Not terminal: the walk continues, so the caller is told to ask again. + expect(await cursor.next()).toEqual([]); + expect(authorized.mock.calls).toEqual([[[], { terminal: false }]]); + }); + + it("still authorizes the terminal answer after empty windows disclosed no rows", async () => { + // The empty windows were authorized, but none of them answered the query. Treating "authorized + // something" as "disclosed something" would let the zero-result answer out unaudited. + const seen: string[] = []; + const cursor = new TokenCursor({ + fetchPage: tokenApi([ + ...Array.from({ length: 12 }, (_, index) => ({ items: [], nextToken: `w${index}` })), + { items: [] }, + ]), + pageSize: 2, + authorizePage: async (items, { terminal }) => + void seen.push(`${terminal ? "terminal" : "window"}:${items.length}`), + }); + + expect(await cursor.next()).toEqual([]); + expect(await cursor.next()).toBeNull(); + + expect(seen).toEqual(["window:0", "terminal:0"]); + // Still at most once: exhaustion asked again emits no duplicate. + expect(await cursor.next()).toBeNull(); + expect(seen).toHaveLength(2); + }); + + it("authorizes the zero-result answer a walk that disclosed nothing still gives", async () => { + // `searchUsers(email) -> no matches` is an existence oracle: it answers a question about + // provider data, so it cannot reach the gadget unaudited just because no row came back. + const authorized = vi.fn(async () => {}); + const cursor = new TokenCursor({ + fetchPage: tokenApi([{ items: [] }]), + authorizePage: authorized, + pageSize: 2, + }); + + expect(await cursor.next()).toBeNull(); + expect(authorized.mock.calls).toEqual([[[], { terminal: true }]]); + + // Exhaustion is idempotent: asking again repeats no observation. + expect(await cursor.next()).toBeNull(); + expect(authorized).toHaveBeenCalledOnce(); + }); + + it("does not re-authorize exhaustion for a walk that already returned a page", async () => { + const authorized = vi.fn(async () => {}); + const cursor = new TokenCursor({ + fetchPage: tokenApi([{ items: [{ id: 1, open: true }] }]), + authorizePage: authorized, + pageSize: 2, + }); + + expect(ids(await cursor.next())).toEqual([1]); + expect(await cursor.next()).toBeNull(); + // The page was authorized; the `null` that follows discloses nothing new. + expect(authorized.mock.calls).toEqual([[[{ id: 1, open: true }], { terminal: false }]]); + }); + + it("re-authorizes a refused zero-result answer on the next call", async () => { + let call = 0; + const authorized = vi.fn(async () => { + if (++call === 1) throw new Error("authorization unavailable"); + }); + const cursor = new TokenCursor({ + fetchPage: tokenApi([{ items: [] }]), + authorizePage: authorized, + pageSize: 2, + }); + + await expect(cursor.next()).rejects.toThrow("authorization unavailable"); + // The refusal left nothing recorded, so the retry must ask again rather than answer silently. + expect(await cursor.next()).toBeNull(); + expect(authorized).toHaveBeenCalledTimes(2); + }); + + it("drives a real gate through every empty case the documented pattern must survive", async () => { + // The other tests stub `authorizePage`, so they cannot catch a scope the gate itself refuses. + // A spent window and an exhausted walk both arrive with no items, and `{ ids: [] }` is refused. + const { authorizer, seen } = makeAuthorizer(); + using gate = new ObservationGate( + authorizer, + // A `sets` scope needs the strategy that actually checks them. + trackedCollectionObservers({ kv: fakeKv(), hasCollectionAccess: async () => [] })); + const cursor = new TokenCursor({ + fetchPage: tokenApi([ + { items: [], nextToken: "w0" }, + { items: [{ id: 1, open: true }] }, + ]), + pageSize: 2, + remotePageSize: 1, + authorizePage: (issues, { terminal }) => issues.length === 0 + ? gate.authorize( + { title: "Issues", description: terminal ? "None left." : "None visible yet." }, + { kind: "baseline" }) + : gate.authorize( + { title: "Issues", description: `Read ${issues.length} issues.` }, + { kind: "collections", ids: issues.map(issue => issue.id.toString()) }), + }); + + // A page, then exhaustion. Neither may throw out of the gate. + expect(ids(await cursor.next())).toEqual([1]); + expect(await cursor.next()).toBeNull(); + expect(seen).toEqual(["Read 1 issues."]); + }); + + it("refuses a gated page through a real read-only authorizer stub", async () => { + // The gate needs only `ObservationAuthorizer` -- exactly what a catalog or slash-command + // handler is given. + const { authorizer, seen } = makeAuthorizer(); + const strategy = trackedCollectionObservers({ + kv: fakeKv(), + hasCollectionAccess: async (allowed, collectionIds) => + collectionIds.map(id => allowed.includes(id)), + }); + using gate = new ObservationGate(authorizer, strategy); + await strategy.addObserver("limited", ["s1"] as unknown as Fetcher); + + using cursor = new TokenCursor({ + fetchPage: tokenApi([{ items: [{ id: 1, open: true }] }]), + pageSize: 1, + authorizePage: issues => gate.authorize( + { title: "Issues", description: `Read ${issues.length} issues.` }, + { kind: "collections", ids: ["s2"] }), + }); + + // The collaborator cannot see s2, so the derived exclusion refuses the page before its rows + // reach the caller. + await expect(cursor.next()).rejects.toThrow("cannot hide from limited"); + expect(seen).toEqual([]); + }); + + it("keeps authorizing after the session that made it is gone", async () => { + // A cursor is returned to the gadget and walked later, so it outlives the call that made it. + // Real stubs, so the lease's refcount is workerd's rather than the test's: releasing the + // session must not close the handle the walk still holds. + const { authorizer, seen } = makeAuthorizer(); + const session = new ObservationGate( + authorizer, trackedCollectionObservers({ kv: fakeKv(), hasCollectionAccess: async () => [] })); + const walk = session.lease(); + const cursor = new TokenCursor({ + fetchPage: tokenApi([ + { items: [{ id: 1, open: true }], nextToken: "n" }, + { items: [{ id: 2, open: true }] }, + ]), + pageSize: 1, + remotePageSize: 1, + // The cursor takes its own lease and releases it when the walk is dropped. + authorizePage: items => walk.authorize( + { title: "Issues", description: `Read ${items.length} issues.` }, + { kind: "collections", ids: items.map(issue => issue.id.toString()) }), + dispose: () => walk[Symbol.dispose](), + }); + + expect(ids(await cursor.next())).toEqual([1]); + session[Symbol.dispose](); + + // The walk must neither continue unaudited nor become unusable. + expect(ids(await cursor.next())).toEqual([2]); + expect(seen).toEqual(["Read 1 issues.", "Read 1 issues."]); + cursor[Symbol.dispose](); + }); + + it("releases a lease without disturbing the session that opened it", async () => { + const { authorizer, seen } = makeAuthorizer(); + using session = new ObservationGate( + authorizer, trackedCollectionObservers({ kv: fakeKv(), hasCollectionAccess: async () => [] })); + + session.lease()[Symbol.dispose](); + + // The session still holds its own handle, so the walk ending does not end the session. + await session.authorize({ title: "After", description: "Still open." }, { kind: "baseline" }); + expect(seen).toEqual(["Still open."]); + }); + it("rejects page sizes that would never terminate", () => { const fetchPage = tokenApi([]); - expect(() => new TokenCursor({ fetchPage, pageSize: 0 })).toThrow(/positive integer/); - expect(() => new TokenCursor({ fetchPage, pageSize: 2, remotePageSize: 1.5 })) - .toThrow(/positive integer/); + expect(() => new TokenCursor( + { fetchPage, authorizePage, pageSize: 0 })).toThrow(/positive safe integer/); + expect(() => new TokenCursor( + { fetchPage, authorizePage, pageSize: 2, remotePageSize: 1.5 })) + .toThrow(/positive safe integer/); }); }); diff --git a/packages/gatekeeper-kit/__tests__/workerd/observer-storage.test.ts b/packages/gatekeeper-kit/__tests__/workerd/observer-storage.test.ts index 525afba27e..b34edcafc2 100644 --- a/packages/gatekeeper-kit/__tests__/workerd/observer-storage.test.ts +++ b/packages/gatekeeper-kit/__tests__/workerd/observer-storage.test.ts @@ -41,9 +41,8 @@ describe("tracked observers over real Durable Object storage", () => { it("fences admission through a second tracker while a withheld read is open", async () => { const tracker = host("withheld-fence"); - // The fence is in memory, keyed by the storage object, so it only reaches a second tracker if - // `ctx.storage.kv` answers with one identity per Durable Object. Nothing in the Workers types - // promises that; a fresh wrapper per access would admit an observer the open read excludes. + // The fence is a durable marker under the binding's own storage, so it reaches a second + // tracker built over the same `ctx.storage.kv` -- and would reach a later activation too. expect(await tracker.admitDuringWithheldRead("mallory", { allowed: [] })) .toMatch(/can no longer be observed/); }); diff --git a/packages/gatekeeper-kit/__tests__/workerd/worker.ts b/packages/gatekeeper-kit/__tests__/workerd/worker.ts index 997ce453aa..135c69f0ea 100644 --- a/packages/gatekeeper-kit/__tests__/workerd/worker.ts +++ b/packages/gatekeeper-kit/__tests__/workerd/worker.ts @@ -2,6 +2,7 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { ObserverTracker } from "../../src/observers"; +export { ConformanceAccount, ConformanceResource, ConformanceVerifier } from "./conformance/gatekeeper"; type VerifierProps = { allowed: readonly string[]; dropVerdicts?: number }; @@ -11,24 +12,24 @@ type VerifierProps = { allowed: readonly string[]; dropVerdicts?: number }; * Object storage refuses it outright, flag or no flag. */ export class FixtureVerifier extends WorkerEntrypoint { - async hasSets(setIds: readonly string[]): Promise { - const verdicts = setIds.map(setId => this.ctx.props.allowed.includes(setId)); + async hasSets(collectionIds: readonly string[]): Promise { + const verdicts = collectionIds.map(collectionId => this.ctx.props.allowed.includes(collectionId)); return verdicts.slice(0, verdicts.length - (this.ctx.props.dropVerdicts ?? 0)); } } -type Verifier = { hasSets(setIds: readonly string[]): Promise }; +type Verifier = { hasSets(collectionIds: readonly string[]): Promise }; /** Drives a tracker whose storage is a real DO's, so a persisted stub is a persisted stub. */ export class TrackerHost extends DurableObject { readonly #tracker = this.#newTracker(); /** Built the way a gatekeeper that rebuilds one per accessor gets it: fresh from - * `this.ctx.storage.kv`, which the in-memory withhold fence is keyed by. */ + * `this.ctx.storage.kv`, whose durable markers every tracker over it reads. */ #newTracker(): ObserverTracker { return new ObserverTracker({ kv: this.ctx.storage.kv, - hasSetAccess: (verifier, setIds) => verifier.hasSets(setIds), + hasCollectionAccess: (verifier, collectionIds) => verifier.hasSets(collectionIds), }); } @@ -53,17 +54,17 @@ export class TrackerHost extends DurableObject { return this.#tracker.observerIds(); } - /** Reveals `setIds`, commits, and reports who the read had to be hidden from. */ - async reveal(setIds: string[]): Promise { - const check = await this.#tracker.prepareObservation(setIds); + /** Reveals `collectionIds`, commits, and reports who the read had to be hidden from. */ + async reveal(collectionIds: string[]): Promise { + const check = await this.#tracker.prepareObservation(collectionIds); const excluded = [...(check.excludeObservers ?? [])]; check.commit(); return excluded; } /** Proves the stub survived the write: re-read from storage and call it. */ - async askStored(id: string, setIds: string[]): Promise { - return await this.ctx.storage.kv.get(`observer:${id}`)?.hasSets(setIds); + async askStored(id: string, collectionIds: string[]): Promise { + return await this.ctx.storage.kv.get(`observer:${id}`)?.hasSets(collectionIds); } } diff --git a/packages/gatekeeper-kit/src/action-journal.ts b/packages/gatekeeper-kit/src/action-journal.ts index e64bd579bb..70ac1dce59 100644 --- a/packages/gatekeeper-kit/src/action-journal.ts +++ b/packages/gatekeeper-kit/src/action-journal.ts @@ -1,18 +1,37 @@ /** Durable action lifecycle storage for approval, simulation, retry, and retention. */ import type { KvScannable } from "./kv"; +import { reservedObserverOverlap } from "./observer-keys"; import { requirePositiveInt } from "./positive-int"; /** The Durable Object KV surface used by the action journal. */ export type ActionJournalKv = KvScannable; -/** Storage keys, overridable so a port keeps reading the records it already wrote. */ -export type JournalKeys = { - /** Stores the next unused ID, never the last issued ID. */ - nextIdKey?: string; - /** Must not contain `nextIdKey`, which would then be scanned as a record. */ - recordPrefix?: string; -}; +/** + * Where one journal's records live. Two journals over one Durable Object must not share a + * keyspace: they would share ids and capacity while each bound action set serializes apply and + * reject on its own in-memory queue, so nothing would order their provider calls against each + * other. + * + * `namespace` derives every key and is what new code passes. `legacyKeys` is the escape hatch for + * a port that must keep reading records it already wrote; the two are mutually exclusive. + */ +export type JournalKeys = + | { + /** Distinguishes this journal's keys from every other journal over the same storage. */ + namespace: string; + legacyKeys?: never; + } + | { + namespace?: never; + /** The exact pre-kit key layout. Only a port with existing records passes this. */ + legacyKeys: { + /** Stores the next unused ID, never the last issued ID. */ + nextIdKey: string; + /** Must not contain `nextIdKey`, which would then be scanned as a record. */ + recordPrefix: string; + }; + }; /** * Journal lifecycle state. Applied records live only in retained storage; claimed records have a @@ -20,22 +39,60 @@ export type JournalKeys = { */ type JournalState = "staged" | "pending" | "claimed" | "failed" | "applied"; -/** A stored action. Failed records always include a reason. */ +/** + * The authority an action was staged under, opaque and equality-only. Nothing may be inferred from + * its content or ordering, and the journal never interprets it: a kind the set declares + * `"authority"` is staged with one and apply refuses a record whose value has changed. + * + * The field is named `generation` because the common fence is the connection generation, which + * makes a `CredentialRead` structurally an `ActionFence`. A consumer wanting account-scoped rather + * than connection-scoped fencing stores its own stable provider account id here instead, and + * passes that same id at apply. + */ +export type ActionFence = { generation: string }; + +/** + * A stored action. Failed records always include a reason, and classify whether the provider + * effect is known not to have landed or is simply unknown. + */ export type JournalRecord = - | { state: Exclude; action: A; error?: never } - | { state: "failed"; action: A; error: string }; + | { + state: Exclude; action: A; fence?: ActionFence; error?: never; + undispatched?: never; outcome?: never; + } + | { + state: "failed"; action: A; fence?: ActionFence; error: string; + /** The apply refused before the handler ran, so a rejection still owes its cleanup. */ + undispatched?: true; + /** + * Whether the provider effect is known absent. `"unknown"` means the handler may have + * committed it: the record is never replayed, never pruned, and strands no dependents, since + * the reference it provides may in fact exist. Absent reads as `"not-applied"`, which is the + * only classification a record written before this field could have had. + */ + outcome?: "not-applied" | "unknown"; + }; /** An action ID and payload used by simulation. */ export type JournalEntry = { readonly id: number; readonly action: A }; +/** One storage-bounded page of retained actions. */ +export type RetainedActionPage = { + /** Valid applied records found in this storage page. */ + entries: JournalEntry[]; + /** Opaque position for the next scan; absent once storage returned a short page. */ + nextCursor?: string; +}; + // Reads project pending and claimed actions; staged is not yet proven submitted, and failed is terminal. const PROJECTED: readonly JournalState[] = ["pending", "claimed"]; const UNDECIDED: readonly JournalState[] = ["pending"]; -// The version distinguishes kit records from legacy rows that may have the same shape. -// Unmarked rows must go through `upgradeRecord`. -const JOURNAL_VERSION = 1; +// The version distinguishes kit records from legacy rows that may have the same shape, so it skips +// 1: a port whose own pre-kit rows carry `v: 1` would have them read as kit records, bypassing +// `upgradeRecord`. Unmarked rows must go through it. +const JOURNAL_VERSION = 2; type StoredJournalRecord = JournalRecord & { v: typeof JOURNAL_VERSION }; @@ -49,6 +106,9 @@ const FAILURE_REASON_LOST = "This action failed, and the reason was not recorded // Bound the reason so a post-provider storage write cannot exceed DO limits. const MAX_FAILURE_REASON = 1024; +// Keeps a derived key unambiguous: no separator that could forge a prefix boundary. +const JOURNAL_NAMESPACE = /^[A-Za-z0-9_-]+$/; + export type ActionJournalOptions = JournalKeys & { /** * Converts a legacy unresolved record. Resolved records must return `undefined`, or their provider @@ -69,7 +129,7 @@ export type ActionJournalOptions = JournalKeys & { * * @example * ```ts - * const journal = new ActionJournal(ctx.storage.kv); + * const journal = new ActionJournal(ctx.storage.kv, { namespace: "calendar" }); * const pending = createSimulationView( * journal.listPending(), * action => action.projectIds, @@ -89,12 +149,16 @@ export class ActionJournal { /** * Creates an action journal. * @param kv Durable Object storage for journal records. - * @param options Storage keys, migration, and capacity settings. + * @param options `namespace` (or a port's `legacyKeys`), migration, and capacity settings. */ - constructor(kv: ActionJournalKv, options: ActionJournalOptions = {}) { + constructor(kv: ActionJournalKv, options: ActionJournalOptions) { this.#kv = kv; - this.#nextIdKey = options.nextIdKey ?? "pending:nextActionId"; - this.#prefix = options.recordPrefix ?? "pending:action:"; + const keys = options.legacyKeys ?? { + nextIdKey: `${options.namespace}:nextActionId`, + recordPrefix: `${options.namespace}:action:`, + }; + this.#nextIdKey = keys.nextIdKey; + this.#prefix = keys.recordPrefix; // Outside the pending prefix, not beneath it: a retained record must fall out of that scan. this.#retainedPrefix = `retained:${this.#prefix}`; // One key, not a tier: the non-numeric suffix keeps it out of every id scan. @@ -102,8 +166,13 @@ export class ActionJournal { this.#upgradeRecord = options.upgradeRecord; this.#maxPending = requirePositiveInt("maxPending", options.maxPending ?? DEFAULT_MAX_PENDING); - // Only ports pass these, and a silent overlap corrupts the keyspace: a counter under the record - // prefix is scanned as a record, and a record prefix under the retained one un-tiers the scan. + if (options.legacyKeys === undefined && !JOURNAL_NAMESPACE.test(options.namespace ?? "")) { + throw new Error( + `Journal namespace "${options.namespace}" must match ${JOURNAL_NAMESPACE.source}.`); + } + // A silent overlap corrupts the keyspace: a counter under the record prefix is scanned as a + // record, and a record prefix under the retained one un-tiers the scan. Only reachable through + // `legacyKeys`, since a derived pair cannot collide. if (!this.#prefix) throw new Error("recordPrefix must not be empty."); if (this.#nextIdKey.startsWith(this.#prefix) || this.#prefix.startsWith(this.#nextIdKey) || this.#nextIdKey.startsWith(this.#retainedPrefix) @@ -113,14 +182,22 @@ export class ActionJournal { if (this.#retainedPrefix.startsWith(this.#prefix)) { throw new Error(`recordPrefix "${this.#prefix}" would contain its own retained tier.`); } + // Observer storage is scanned by prefix, so records landing inside it come back as verifiers + // or as an unsettled withheld read -- type-confused ACL checks, and sharing fenced for good. + const observerOverlap = reservedObserverOverlap(this.#prefix) + ?? reservedObserverOverlap(this.#nextIdKey); + if (observerOverlap !== undefined) { + throw new Error(`Journal keys overlap the reserved observer prefix "${observerOverlap}".`); + } } /** * Reserves and stages the next action. * @param action Payload to store. + * @param fence Connection generation the staging operation ran under. * @returns Allocated action ID. */ - allocate(action: A): number { + allocate(action: A, fence?: ActionFence): number { this.#requireCapacity(); const id = this.#kv.get(this.#nextIdKey) ?? 1; // Occupancy or retired-id memory at this id means the counter is behind -- a port pointed @@ -132,7 +209,8 @@ export class ActionJournal { + `"${this.#nextIdKey}" must hold the next unused id, not the last issued one.`); } this.#kv.put(this.#nextIdKey, id + 1); - this.#write(this.#pendingKey(id), { state: "staged", action }); + this.#write(this.#pendingKey(id), + { state: "staged", action, ...(fence ? { fence: { generation: fence.generation } } : {}) }); return id; } @@ -164,14 +242,28 @@ export class ActionJournal { * Records a terminal failure and removes the action from simulation. * @param id Action ID that failed. * @param error Display-safe failure reason. + * @param options `undispatched` when the apply refused before reaching the handler; `outcome` + * classifies whether the provider effect is known absent, defaulting to `"not-applied"`. */ - markFailed(id: number, error: string): void { + markFailed( + id: number, + error: string, + options: { undispatched?: boolean; outcome?: "not-applied" | "unknown" } = {}, + ): void { const record = this.#transitionable(id, ["staged", "pending", "claimed"]); if (record) { const reason = error.length > MAX_FAILURE_REASON ? `${error.slice(0, MAX_FAILURE_REASON)}\u2026` : error; - this.#write(this.#pendingKey(id), { state: "failed", action: record.action, error: reason }); + this.#write(this.#pendingKey(id), { + state: "failed", + action: record.action, + error: reason, + ...(options.undispatched ? { undispatched: true } as const : {}), + // Only the non-default is stored, so a record carries no key for the ordinary case. + ...(options.outcome === "unknown" ? { outcome: "unknown" } as const : {}), + ...(record.fence ? { fence: record.fence } : {}), + }); } } @@ -205,6 +297,7 @@ export class ActionJournal { this.#write(this.#retainedKey(id), { state: "applied", action: action ?? record.action, + ...(record.fence ? { fence: record.fence } : {}), }); this.#kv.delete(this.#pendingKey(id)); } @@ -220,16 +313,20 @@ export class ActionJournal { /** * Removes an applied action, remembering the id so a replayed resolution settles instead of - * erroring or mislabeling it. Memory is bounded to the prunable allowance. + * erroring or mislabeling it. Idempotent, so an interrupted retire can be finished by the next + * apply. Memory is bounded to the prunable allowance. * @param id Action ID to retire. */ retire(id: number): void { - const ids = this.#kv.get(this.#appliedIdsKey) ?? []; - ids.push(id); - // Removal first: split writes then degrade to a retryable unknown id, never to a remembered - // apply whose record still projects. + const ids = this.#appliedIds(); + // Tombstone first: split writes then degrade to a stale record the scans below filter out and + // the next apply retires, never to a remembered apply whose record still projects. A failed + // tombstone write leaves the record applicable again, which at-least-once apply already owns. + if (!ids.includes(id)) { + ids.push(id); + this.#kv.put(this.#appliedIdsKey, ids.slice(-this.#maxPending * PRUNABLE_RECORD_FACTOR)); + } this.remove(id); - this.#kv.put(this.#appliedIdsKey, ids.slice(-this.#maxPending * PRUNABLE_RECORD_FACTOR)); } /** @@ -238,7 +335,17 @@ export class ActionJournal { * @returns Whether the id is within the retired-action memory. */ wasApplied(id: number): boolean { - return (this.#kv.get(this.#appliedIdsKey) ?? []).includes(id); + return this.#appliedIds().includes(id); + } + + /** @returns The action IDs held in the retired-action memory. */ + #appliedIds(): number[] { + return this.#kv.get(this.#appliedIdsKey) ?? []; + } + + /** @returns The retired-action memory as a set, for scans that test many ids. */ + #appliedSet(): Set { + return new Set(this.#appliedIds()); } /** @@ -250,6 +357,51 @@ export class ActionJournal { return this.#read(this.#retainedKey(id)) !== undefined; } + /** + * Lists one storage-bounded page of retained actions. Invalid or non-applied rows are omitted + * (and a row an interrupted `retire` left behind is deleted) but still consume the storage limit, + * so an empty `entries` array may carry `nextCursor`. Keep calling with the returned opaque + * cursor until it is absent. Call `retire(id)` to remove a retained action while preserving + * applied-id replay memory. + * @param options Storage page size and the previous page's opaque cursor. + * @returns Retained actions from this storage page and its continuation position. + */ + listRetained( + options: { limit: number; cursor?: string }, + ): RetainedActionPage { + const limit = requirePositiveInt("limit", options.limit); + const entries: JournalEntry[] = []; + // A `retire` whose delete threw leaves a tombstoned row here. Finish that delete rather than + // hide the row: the tombstone memory is bounded, so a row merely omitted would resurface -- + // and hand the consumer a finished action twice -- once later retires evict its id. + const applied = this.#appliedSet(); + const stale: string[] = []; + let scanned = 0; + let lastKey: string | undefined; + for (const [key, raw] of this.#kv.list({ + prefix: this.#retainedPrefix, + ...(options.cursor === undefined ? {} : { startAfter: options.cursor }), + limit, + })) { + scanned++; + lastKey = key; + const id = this.#idFrom(key, this.#retainedPrefix); + if (id === undefined) continue; + if (applied.has(id)) { + stale.push(key); + continue; + } + const record = this.#coerce(raw); + if (record?.state === "applied") entries.push({ id, action: record.action }); + } + // After the walk, so the scan never deletes under the live list iterator. + for (const key of stale) this.#kv.delete(key); + return { + entries, + ...(scanned === limit && lastKey !== undefined ? { nextCursor: lastKey } : {}), + }; + } + /** @returns Actions visible to simulation, ordered by ID. */ listPending(): JournalEntry[] { return this.#scan(PROJECTED); @@ -267,13 +419,14 @@ export class ActionJournal { */ #scan(states: readonly JournalState[]): JournalEntry[] { const found: JournalEntry[] = []; + const applied = this.#appliedSet(); for (const [key, raw] of this.#kv.list({ prefix: this.#prefix })) { const record = this.#coerce(raw); if (record === undefined || !states.includes(record.state)) continue; const id = this.#idFrom(key); - // A record left behind by an interrupted `retain` is applied, not pending: projecting it - // would simulate an effect the provider has already made real. - if (id === undefined || this.isRetained(id)) continue; + // A record left behind by an interrupted `retain` or `retire` is applied, not pending: + // projecting it would simulate an effect the provider has already made real. + if (id === undefined || applied.has(id) || this.isRetained(id)) continue; found.push({ id, action: record.action }); } return found.toSorted((a, b) => a.id - b.id); @@ -284,18 +437,25 @@ export class ActionJournal { let unresolved = 0; const staged: number[] = []; const failed: number[] = []; + const applied = this.#appliedSet(); for (const [key, raw] of this.#kv.list({ prefix: this.#prefix })) { // A key this journal cannot name an id for is not its record: counting one would hold a slot // no approval can clear, and pruning one would delete a stranger's key. const id = this.#idFrom(key); if (id === undefined) continue; - const state = this.#coerce(raw)?.state; - // An interrupted `retain` leaves a stale source record here, whatever its state; the retained - // tier decides, as it does for `get` and `listPending`. - if (state === undefined || this.isRetained(id)) continue; - if (state === "staged") staged.push(id); - else if (state === "failed") failed.push(id); - else unresolved += 1; + const record = this.#coerce(raw); + // An interrupted `retain` or `retire` leaves a stale source record here, whatever its state; + // the retained tier and the retired-id memory decide, as they do for `get` and `listPending`. + if (record === undefined || applied.has(id) || this.isRetained(id)) continue; + if (record.state === "staged") staged.push(id); + // An undispatched failure holds a slot rather than joining the prunable set: only a + // rejection can release what its staging set up, so discarding the record would strand + // those artifacts for good. An unknown outcome holds one for the opposite reason -- it is + // the only record saying the provider may already have changed, and pruning it would evict + // that warning first. Blocking is recoverable -- the user rejects it. + else if (record.state !== "failed" || record.undispatched + || record.outcome === "unknown") unresolved += 1; + else failed.push(id); } if (unresolved >= this.#maxPending) { throw new Error( @@ -333,10 +493,11 @@ export class ActionJournal { /** * Parses a canonical action ID from a storage key. * @param key Scanned storage key. + * @param prefix Prefix preceding the id. * @returns The action ID, or `undefined` for an unrelated key. */ - #idFrom(key: string): number | undefined { - const suffix = key.slice(this.#prefix.length); + #idFrom(key: string, prefix = this.#prefix): number | undefined { + const suffix = key.slice(prefix.length); return /^[1-9]\d*$/.test(suffix) ? Number(suffix) : undefined; } @@ -359,7 +520,13 @@ export class ActionJournal { */ #transition(id: number, from: readonly JournalState[], next: Exclude) { const record = this.#transitionable(id, from); - if (record) this.#write(this.#pendingKey(id), { state: next, action: record.action }); + if (record) { + this.#write(this.#pendingKey(id), { + state: next, + action: record.action, + ...(record.fence ? { fence: record.fence } : {}), + }); + } } /** @@ -399,13 +566,21 @@ export class ActionJournal { if ("v" in raw && raw.v === JOURNAL_VERSION) { // The marker is storage detail; callers see the record only. One fallback here, not one per // reader, keeps the type's promise that a failed record explains itself. - const { state, action, error } = raw as StoredJournalRecord; + const { state, action, error, fence, undispatched, outcome } = + raw as StoredJournalRecord; + const carried = fence ? { fence: { generation: fence.generation } } : {}; return state === "failed" - ? { state, action, error: error ?? FAILURE_REASON_LOST } - : { state, action }; + ? { + state, action, error: error ?? FAILURE_REASON_LOST, ...carried, + ...(undispatched ? { undispatched: true } as const : {}), + // A record written before this field existed reads as the default, `"not-applied"`. + ...(outcome === "unknown" ? { outcome: "unknown" } as const : {}), + } + : { state, action, ...carried }; } // Anything else was written by whatever this gatekeeper stored before adopting the journal, - // and since it only kept records awaiting approval, it was pending. + // and since it only kept records awaiting approval, it was pending. An upgraded record carries + // no fence: nothing staged it under a generation this journal recorded. const upgraded = this.#upgradeRecord?.(raw); return upgraded === undefined ? undefined : { state: "pending", action: upgraded }; } diff --git a/packages/gatekeeper-kit/src/actions.ts b/packages/gatekeeper-kit/src/actions.ts index c9daecfe3e..6b3487b1d6 100644 --- a/packages/gatekeeper-kit/src/actions.ts +++ b/packages/gatekeeper-kit/src/actions.ts @@ -6,20 +6,23 @@ import type { ActionDescription, ActionKind, ApprovalQueue, + GitCache, } from "@gadgets/workshop-shared/gatekeeper"; -import { ActionJournal } from "./action-journal"; +import { ActionJournal, type ActionFence } from "./action-journal"; import { SerialTaskQueue } from "./serial-queue"; export { ActionJournal, + type ActionFence, type ActionJournalKv, type ActionJournalOptions, type JournalEntry, type JournalKeys, type JournalRecord, + type RetainedActionPage, } from "./action-journal"; -/** The queue surface staging needs; `gate.actions` and a full stub both satisfy it. */ +/** The queue surface staging needs; a session's own approval-queue stub satisfies it. */ export type ActionSubmitter = Pick, "submitAction">; type ActionLogFields = @@ -36,6 +39,7 @@ const submissions = new WeakMap(); * @param queue Approval queue capability. * @param action Action payload to store. * @param description Approver-facing action description. + * @param fence Connection generation the staging operation ran under, when the action is fenced. * @returns The allocated action ID. */ export function stageAction( @@ -43,11 +47,14 @@ export function stageAction( queue: ActionSubmitter, action: A, description: ActionDescription, + fence?: ActionFence, ): Promise { + // Snapshotted before the lane yields, as `submit` does before `describe`. + const staged = fence && { generation: fence.generation }; let lane = submissions.get(journal); if (!lane) submissions.set(journal, lane = new SerialTaskQueue()); return lane.run(async () => { - const id = journal.allocate(action); + const id = journal.allocate(action, staged); try { await queue.submitAction(id, description); } catch (error) { @@ -64,24 +71,67 @@ export function stageAction( } /** - * Marks an apply failure as terminal and safe to show. Ordinary apply errors remain retryable; in a - * reject handler this class has no special meaning. + * Marks an apply failure as terminal, safe to show, and **known** to have left no usable provider + * effect. Dependents whose references this action was to provide are retired with it. Ordinary + * apply errors remain retryable; in a reject handler this class has no special meaning. + * + * Throw `ActionOutcomeUnknownError` instead whenever the provider may have committed the effect. */ export class ActionApplyError extends Error {} +/** + * Marks an apply failure as terminal with an **unknown** provider outcome: the call may already + * have taken effect. The record is not pruned and no dependent is retired on its account -- a + * reference it was to provide may in fact exist at the provider -- so it stays until the user + * rejects it and the reconciliation warning survives. + * + * Pair it with `claimBeforeApply`. Non-replay rests on the durable claim taken *before* dispatch: + * without one the record is still `pending` when the handler throws, so an activation that dies + * between the provider call and this mark leaves it replayable, which is the effect the class + * exists to prevent. Thrown from an unclaimed definition it still records the outcome, and the kit + * logs that the guarantee was unavailable. + * + * This is the honest classification for a timeout, an aborted request, or any failure after the + * provider was reached. Use `ActionApplyError` only when the effect is known absent. + */ +export class ActionOutcomeUnknownError extends Error {} + /** Message stored when a dispatched action's outcome is unknown. */ export const APPLY_OUTCOME_UNKNOWN_MESSAGE = "This action was interrupted after it was dispatched, " + "so it may or may not have taken effect. Check the provider before submitting it again."; -/** The approver-facing text for one action; its policy fields come from the declaration. */ -export type ActionPresentation = - Pick; +type KitOwnedField = "awaitDecision" | "autoApprovable" | "actionKind"; +type ProviderOwnedField = "title" | "description" | "pushedCommits" | "implementsRevert"; +type Unclassified = Exclude; /** - * Durable action ID available to handlers. It is stable across retries and can seed provider - * idempotency keys. + * The approver-facing description for one action; policy fields come from the declaration. A field + * added to `ActionDescription` must join one of the two classifications above, or this resolves to + * the error object and every `describe` implementation fails to compile. */ -export type ActionContext = { readonly id: number }; +export type ActionPresentation = [Unclassified] extends [never] + ? Pick + : { CLASSIFY_NEW_ActionDescription_FIELD: Unclassified }; + +/** + * Durable action ID and apply-time context available to handlers. The ID is stable across retries + * and can seed provider idempotency keys. + */ +export type ActionContext = { + readonly id: number; + /** + * Action-scoped git cache the overseer handed `applyAction`; absent outside apply, and for + * gatekeepers that pass none. + */ + readonly gitCache?: RpcStub; + /** + * The connection fence captured at submit, when the submitter passed one. Apply already refuses + * a record whose fence does not match the generation handed to `apply()`; a handler wanting + * strict enforcement compares this against the `CredentialRead` its own operation runs under, + * which also catches a reconnect landing after that entry check. + */ + readonly fence?: ActionFence; +}; /** How one kind of action is described to the approver and carried out once approved. */ export type ActionDefinition = { @@ -123,7 +173,10 @@ export type ActionDefinition = { */ apply(payload: Payload, host: Host, ctx: ActionContext): Promise; /** - * Handles a rejected action. + * Releases what staging set up for an action that will never apply. Also runs when the user + * rejects a terminal failure the apply refused *before* dispatch, whose artifacts are still + * unreleased; a failure the handler itself raised is cleared without it, since the handler owns + * whatever its partial effect left behind. * @param payload Stored action payload. * @param host Bound provider host. * @param ctx Durable action context. @@ -134,8 +187,32 @@ export type ActionDefinition = { /** How a resolution ended, for cache invalidation. */ export type ResolveOutcome = "applied" | "rejected" | "failed" | "reverted"; +/** + * Whether an action's payload still means anything under a different authority. + * + * - `"authority"` — pin it. `submit` requires a fence, and apply refuses a record whose authority + * has since changed. + * - `"none"` — the action is authority-independent, so no fence is captured or checked. + * + * The kit never interprets the fence, so *which* authority it names is the provider's to choose. + * The common one is the connection generation from the `CredentialRead` the staging operation ran + * under: `CredentialCoordinator` rotates it on `connect()` and `clear()` only, never on refresh, + * so a same-account re-authorization trips the fence too. A provider that wants an action to + * survive re-authorization of the same account stores its own stable account id instead and passes + * that at apply — the comparison is opaque equality either way. + */ +export type FencePolicy = "authority" | "none"; + /** Cross-cutting policy for a whole action set, as opposed to one kind's behavior. */ -export type ActionSetOptions = { +export type ActionSetOptions = { + /** + * The fence every kind takes unless `fenceOverrides` says otherwise. Required: a set that + * silently defaulted to unfenced is how an action approved under one provider account comes to + * be applied under the next one. + */ + fence: FencePolicy; + /** Kinds whose fence differs from the set's, named one by one so each is a decision. */ + fenceOverrides?: Partial>; /** Keeps applied records for revert or consumer-managed retention. */ retainApplied?: boolean; /** @@ -145,6 +222,17 @@ export type ActionSetOptions = { * @returns Completion, optionally asynchronous. */ afterResolve?(host: Host, outcome: ResolveOutcome): void | Promise; + /** + * Reports whether a provisional reference from `dependsOn` has been bound to a real provider id + * (e.g. `(host, ref) => host.provisionalIds.isResolved(ref)`). Required as soon as any + * definition declares `dependsOn`: apply refuses to run a handler whose references are + * unresolved instead of passing provisional strings to the provider. + * The host is passed because the bindings are durable provider state, which lives per resource. + * @param host Provider host this set is bound to. + * @param ref Provisional reference the action depends on. + * @returns Whether the reference names a real provider id. + */ + isResolvedReference?(host: Host, ref: string): boolean; /** Vendor id for log attribution. */ vendorId?: string; }; @@ -152,6 +240,18 @@ export type ActionSetOptions = { /** A journal entry tagged with the kind that knows how to resolve it. */ export type TaggedAction = { [K in keyof M]: { kind: K; payload: M[K] } }[keyof M]; +/** Approval-time context the overseer hands `Gatekeeper.applyAction`. */ +export type ActionApplyContext = { + /** The action-scoped git cache stub, for handlers that touch git. */ + gitCache?: RpcStub; + /** + * The current value of whatever authority the fence carries, compared by opaque equality. For + * the common connection fence that is `CredentialSource.read()`'s `generation`; for a provider + * fencing on a stable account id, it is that id. Required to apply an authority-fenced record. + */ + generation?: string; +}; + /** The action set bound to one resource's journal and host. */ export type BoundActionSet> = { /** @@ -160,17 +260,30 @@ export type BoundActionSet> = { * @param queue Approval queue capability. * @param kind Declared action kind. * @param payload Action payload. + * @param options `fence` is **required** for a kind the set declares `"authority"`, and refused + * for one declared `"none"`. For the common connection fence, pass the `CredentialRead` the + * staging operation itself ran under — structurally an `ActionFence`, so `{ fence: read }` works + * verbatim. Never a second read taken here, and never a shared accessor: either can move between + * the payload's read and this call, pinning old-connection data to the new connection. * @returns The allocated action ID. */ - submit(queue: ActionSubmitter, kind: K, payload: M[K]): Promise; + submit( + queue: ActionSubmitter, + kind: K, + payload: M[K], + options?: { fence?: ActionFence }, + ): Promise; /** * Applies an action, at-least-once across activations unless its definition sets * `claimBeforeApply`; re-applying an applied ID is a no-op. Resolution is serialized with * rejection to prevent a duplicate provider call. A missing definition records a terminal * failure so the action can still be rejected. * @param id Action ID to apply. + * @param context Apply-time context from the overseer: `gitCache` is the `cache` stub + * `applyAction(action, cache)` received (git-free gatekeepers omit it), and `generation` is the + * account's current connection generation, required for an action submitted with a fence. */ - apply(id: number): Promise; + apply(id: number, context?: ActionApplyContext): Promise; /** * Rejects an action, including one whose definition was removed after submission. * @param id Action ID to reject. @@ -208,8 +321,13 @@ export type ActionSet> = { // Pending action references used to find stranded dependents. type ActionRefs = { id: number; provides: readonly string[]; dependsOn: readonly string[] }; -// Find actions transitively stranded by unresolved provisional references. -function strandedBy(dead: readonly string[], pending: readonly ActionRefs[]): number[] { +// Find actions transitively stranded by unresolved provisional references. `isDead` prunes the +// walk: a reference it clears still resolves for dependents, however its provider ended. +function strandedBy( + dead: readonly string[], + pending: readonly ActionRefs[], + isDead: (ref: string) => boolean = () => true, +): number[] { const dependents = new Map(); const provides = new Map(); for (const entry of pending) { @@ -228,7 +346,7 @@ function strandedBy(dead: readonly string[], pending: readonly ActionRefs[]): nu for (const id of dependents.get(ref) ?? []) { if (stranded.has(id)) continue; stranded.add(id); - unresolved.push(...(provides.get(id) ?? [])); + unresolved.push(...(provides.get(id) ?? []).filter(isDead)); } } return [...stranded]; @@ -236,30 +354,41 @@ function strandedBy(dead: readonly string[], pending: readonly ActionRefs[]): nu /** * Declares a resource's action handlers. Apply is at-least-once by default; irreversible calls use - * `claimBeforeApply`, and uncertain non-replayable failures use `ActionApplyError`. + * `claimBeforeApply`, a failure known to have left no effect uses `ActionApplyError`, and one the + * provider may have committed uses `ActionOutcomeUnknownError`. * @param definitions Action handlers keyed by kind. - * @param options Set-wide retention and resolution policy. + * @param options Set-wide fence, retention, and resolution policy. * @returns An action set ready to bind. * * @example * ```ts * const declared = defineActions({ * createTask: { + * kind: { tag: "create-task", label: "Create a task" }, * delivery: "continue-with-simulation", * claimBeforeApply: true, * describe: task => ({ * title: `Create task "${task.title}"`, * description: `Creates the task in project ${task.projectId}.`, + * implementsRevert: false, * }), * apply: (task, api) => api.createTask(task), * }, + * }, { fence: "authority" }); + * + * // Staged inside the operation that produced the payload, so the fence is that operation's own + * // read. A second `read()` taken here could land after a reconnect and pin the old connection's + * // data to the new one, which is why the kit cannot capture the fence for you. + * await this.#creds.run(async (creds, read) => { + * const task = await api.draftTask(creds, input); + * return declared.bind(journal, api) + * .submit(queue, "createTask", task, { fence: read }); * }); - * await declared.bind(journal, api).submit(gate.actions, "createTask", task); * ``` */ export function defineActions>( definitions: { [K in keyof M]: ActionDefinition }, - options: ActionSetOptions = {}, + options: ActionSetOptions, ): ActionSet { const labelByTag = new Map(); // One entry per tag: siblings sharing one are governed as a group, and the loop below rejects a @@ -269,7 +398,19 @@ export function defineActions>( // The cast above is the one place the payload type is erased: TypeScript cannot correlate a // tagged union's payload with its definition. const byName = new Map(declared); + // Mapped like `byName`, and for the same reason: a kind named after an `Object.prototype` member + // would otherwise resolve an inherited one, reading as a policy that is neither "authority" nor + // "none" and silently skipping the fence. `Object.entries` yields own properties only. + const fenceByKind = new Map(Object.entries(options.fenceOverrides ?? {})); + const fencePolicyFor = (kind: keyof M): FencePolicy => + fenceByKind.get(String(kind)) ?? options.fence; for (const [name, definition] of declared) { + // Without it apply passes provisional strings to the provider, and the cascade's `unbound` + // predicate reads every reference as dead. One omission, wrong in both directions. + if (definition.dependsOn && options.isResolvedReference === undefined) { + throw new Error( + `Action "${name}" declares dependsOn, so the set needs isResolvedReference.`); + } // Auto-approval rules key on the tag, so without a kind the flag could never take effect. if (definition.autoApprovable === true && !definition.kind) { throw new Error(`Action "${name}" declares autoApprovable without a kind.`); @@ -312,6 +453,13 @@ export function defineActions>( // Use a Map so stale stored kinds cannot resolve inherited object members. const definitionFor = (entry: TaggedAction) => byName.get(String(entry.kind)); + const requireGeneration = (id: number, at?: { generation?: string }): string => { + if (at?.generation === undefined) { + throw new Error(`Action ${id} is fenced to a connection generation; pass the current ` + + "generation to apply()."); + } + return at.generation; + }; // Claims missing here were orphaned by an earlier activation and have unknown outcomes. const claimedHere = new Set(); @@ -335,10 +483,15 @@ export function defineActions>( // Retire dependents whose provisional references can no longer resolve. const strandDependents = (id: number, action: TaggedAction): void => { try { - const dead = definitionFor(action)?.provides?.(action.payload) ?? []; + // A reference the provider already bound is not dead, however this action ended: apply + // consults the same oracle before dispatching a handler, and a cascade that ignored it + // would retire dependents whose reference demonstrably exists. + const unbound = (ref: string) => options.isResolvedReference?.(host, ref) !== true; + const dead = (definitionFor(action)?.provides?.(action.payload) ?? []).filter(unbound); if (dead.length === 0) return; - // A staged dependent can race this scan; apply rejects its unresolved reference later. + // The walk itself prunes through `unbound`, so a stranded action's bound references stop + // the cascade. A staged dependent can race this scan; apply rejects it later. const stranded = strandedBy(dead, journal.listUndecided().map(record => { const definition = definitionFor(record.action); return { @@ -346,10 +499,14 @@ export function defineActions>( provides: definition?.provides?.(record.action.payload) ?? [], dependsOn: definition?.dependsOn?.(record.action.payload) ?? [], }; - })); + }), unbound); + // Undispatched: a stranded dependent never reached its handler, so its rejection still + // owes the cleanup. The reason states only what this scan established -- the reference + // was never bound -- rather than asserting anything about the provider call. for (const strandedId of stranded) { journal.markFailed( - strandedId, `This action needed action ${id}, which was not applied.`); + strandedId, `This action needed action ${id}, which did not complete.`, + { undispatched: true }); } if (stranded.length > 0) { attributed.debug("retired actions left unresolvable by a decision", { @@ -369,17 +526,34 @@ export function defineActions>( // Preserve orphaned claims because the provider outcome is unknown. const failOrphanedClaim = async (id: number): Promise => { - journal.markFailed(id, APPLY_OUTCOME_UNKNOWN_MESSAGE); + journal.markFailed(id, APPLY_OUTCOME_UNKNOWN_MESSAGE, { outcome: "unknown" }); await resolved("failed"); - throw new Error(APPLY_OUTCOME_UNKNOWN_MESSAGE); + throw new ActionOutcomeUnknownError(APPLY_OUTCOME_UNKNOWN_MESSAGE); }; - const applyRecord = async (id: number): Promise => { + const applyRecord = async (id: number, context?: ActionApplyContext): Promise => { const record = journal.get(id); // Idempotent for a retry of an applied id ("applied" exists only in the retained tier; // retired ids are remembered durably): erroring here reports an action that succeeded as - // failed. - if (record?.state === "applied" || journal.wasApplied(id)) return; + // failed. A record still here alongside the memory is an interrupted retire, finished now + // so no later reject can report the executed action as rejected -- and a cleanup that + // fails again is logged, not raised: the effect landed, the id is tombstoned, and the + // leftover record already falls out of every scan. + if (journal.wasApplied(id)) { + if (record !== undefined) { + try { + journal.retire(id); + } catch (error) { + attributed.warn("failed to clear an applied action's leftover record", { + event: "actions.retire.heal.failed", + action: id, + error, + }); + } + } + return; + } + if (record?.state === "applied") return; if (record === undefined) throw new Error(`Unknown pending action: ${id}`); // A callback naming the id proves the overseer holds it: promote a record stranded @@ -404,6 +578,30 @@ export function defineActions>( await resolved("failed"); throw new Error(message); } + // `submit` refuses an unfenced authority kind, so an unfenced record under that policy is + // a port's: `upgradeRecord` cannot know what staged one, and applying it would pin nothing. + const unpinned = record.fence === undefined + ? fencePolicyFor(action.kind) === "authority" + && "This action was submitted before this gatekeeper pinned actions to an account." + // The early gate against the common case. A reconnect landing after it is caught only by + // a handler comparing `ctx.fence` against its own operation's read. + : record.fence.generation !== requireGeneration(id, context) + && "This action was approved under a connection that has since been replaced."; + if (unpinned) { + const message = `${unpinned} Reject it and submit it again.`; + journal.markFailed(id, message, { undispatched: true }); + strandDependents(id, action); + await resolved("failed"); + throw new Error(message); + } + // Retryable, never terminal, and no cascade: the providing action may still apply later, + // and the cascade owns terminal marking when it cannot. A set declaring `dependsOn` is + // refused without a resolver, so an absent one here can only mean an empty loop. + for (const ref of definition.dependsOn?.(action.payload) ?? []) { + if (options.isResolvedReference?.(host, ref) === true) continue; + throw new Error(`Action ${id} depends on ${ref}, which is not applied yet. Apply its ` + + "providing action first, or reject this action."); + } try { let result: void | { action?: unknown }; try { @@ -411,12 +609,26 @@ export function defineActions>( journal.markClaimed(id); claimedHere.add(id); } - result = await definition.apply(action.payload, host, { id }); + result = await definition.apply(action.payload, host, { + id, + ...(context?.gitCache ? { gitCache: context.gitCache } : {}), + ...(record.fence ? { fence: record.fence } : {}), + }); } catch (error) { - // Terminal handler failures stop retry; ordinary failures restore the pending claim. + // Three outcomes, not two. A known-empty failure is terminal and cascades; an unknown + // one is terminal and does not, since the reference it owed may exist at the provider; + // anything else is retryable and restores the pending claim. if (error instanceof ActionApplyError) { journal.markFailed(id, error.message); strandDependents(id, action); + } else if (error instanceof ActionOutcomeUnknownError) { + journal.markFailed(id, error.message, { outcome: "unknown" }); + if (!definition.claimBeforeApply) { + attributed.error("unknown outcome recorded without a pre-dispatch claim", { + event: "actions.outcome.unclaimed", + action: id, + }); + } } else journal.restorePending(id); await resolved("failed"); throw error; @@ -446,50 +658,74 @@ export function defineActions>( if (record === undefined) return; // The same proof of receipt apply takes. journal.markSubmitted(id); - if (record.state === "failed") { - // Nothing to undo, so rejecting a terminal failure is the user clearing the record. - journal.remove(id); - await resolved("rejected"); - return; - } if (record.state === "claimed" && !claimedHere.has(id)) { return failOrphanedClaim(id); } const action = record.action; + // Rejecting a terminal failure is the user clearing the record: its handler already ran + // and owns whatever it left behind. One that never reached the handler is the exception — + // its staging artifacts are still the rejection's to release. + const failed = record.state === "failed"; try { - await definitionFor(action)?.reject?.(action.payload, host, { id }); + if (!failed || record.undispatched) { + await definitionFor(action)?.reject?.(action.payload, host, { + id, + ...(record.fence ? { fence: record.fence } : {}), + }); + } } catch (error) { // Same reasoning as a failed apply: the handler may have half-changed simulation state. await resolved("failed"); throw error; } journal.remove(id); - strandDependents(id, action); + // A failure stranded its dependents when it was recorded. + if (!failed) strandDependents(id, action); await resolved("rejected"); }; const set: BoundActionSet = { - submit: async (queue, kind, payload) => { - const definition = definitions[kind]; - // Snapshotted before the first await: the stored payload must be the one describe rendered. + submit: async (queue, kind, payload, { fence } = {}) => { + const definition = byName.get(String(kind)); + if (definition === undefined) throw new Error(`Unknown action kind "${String(kind)}".`); + const policy = fencePolicyFor(kind); + // Declared, not inferred from what the call site happened to pass: an omitted fence on a + // connection-scoped kind is the silent failure this policy exists to prevent, and a + // fence on an authority-independent one would pin an action nothing needed pinned. + if (policy === "authority" && fence === undefined) { + throw new Error(`Action kind "${String(kind)}" is authority-fenced; stage it with the ` + + "authority this operation ran under -- usually its `CredentialRead` -- as `fence`."); + } + if (policy === "none" && fence !== undefined) { + throw new Error(`Action kind "${String(kind)}" is declared authority-independent; ` + + "remove the `fence`, or declare the kind \"authority\"."); + } + // Snapshotted before the first await: the payload must be the one describe rendered, the + // fence the connection this call staged under. payload = structuredClone(payload); - const { title, description, implementsRevert } = await definition.describe(payload, host); + const staged = fence && { generation: fence.generation }; + // Cloned for the same reason as the payload: staging serializes behind the journal's + // lane, and `describe` may still own what it returned. + const { title, description, pushedCommits, implementsRevert } = + structuredClone(await definition.describe(payload, host)); const action = { kind, payload } as TaggedAction; return stageAction(journal, queue, action, { - // Projected, not spread: a port returning a full `ActionDescription` here would + // Destructured, not spread: a port returning a full `ActionDescription` here would // otherwise carry its own `awaitDecision` past the delivery the definition declares. title, description, implementsRevert, + // Spread, so an action with no git, no kind, or no awaited decision puts no key on the + // wire at all. + ...(pushedCommits ? { pushedCommits } : {}), autoApprovable: definition.autoApprovable === true, - // Spread, so a kindless or simulating action puts no key on the wire at all. ...(definition.kind ? { actionKind: definition.kind } : {}), ...(definition.delivery === "await-decision" ? { awaitDecision: true } : {}), - }); + }, staged); }, - apply: id => resolutionQueue.run(() => applyRecord(id)), + apply: (id, context) => resolutionQueue.run(() => applyRecord(id, context)), reject: id => resolutionQueue.run(() => rejectRecord(id)), diff --git a/packages/gatekeeper-kit/src/auth-retry.ts b/packages/gatekeeper-kit/src/auth-retry.ts index 3d5a448fe3..b1de158665 100644 --- a/packages/gatekeeper-kit/src/auth-retry.ts +++ b/packages/gatekeeper-kit/src/auth-retry.ts @@ -14,11 +14,17 @@ export type AuthRetryOptions = { * @returns Whether credentials caused the failure. */ isAuthError(error: unknown): boolean; + /** Acknowledges the operation may execute twice; only replay-safe calls qualify. */ + replayable: true; }; /** - * Retries once after provider-confirmed credential rejection. This helper never reports expiry; - * wrap it in `CredentialSource.run()` when the grant itself should be expired. + * Retries once after provider-confirmed credential rejection. This helper never reports expiry: + * the refresh happens where no account adjudicates it, so recovery and grant death alike stay + * invisible to the Workshop. Provider calls made through a `CredentialSource` get the one-retry + * doctrine from `run(operation, { replayable: true })`, where the account heals past a stale + * credential inside the rejection adjudication and confirmed grant death is reported; this helper + * remains for token flows that hold no source. * @param options Token acquisition and error policy. * @param run Replayable provider operation, executed at most twice. * @returns The first successful result. @@ -28,6 +34,7 @@ export type AuthRetryOptions = { * return withAuthRetry({ * getToken: options => this.#account.getToken(options), * isAuthError: error => error instanceof VendorApiError && error.status === 401, + * replayable: true, * }, token => this.#api.listProjects(token)); * ``` */ diff --git a/packages/gatekeeper-kit/src/cache.ts b/packages/gatekeeper-kit/src/cache.ts index ae4dfba778..99f98ae18f 100644 --- a/packages/gatekeeper-kit/src/cache.ts +++ b/packages/gatekeeper-kit/src/cache.ts @@ -2,18 +2,19 @@ import type { KvReadWrite } from "./kv"; import { requirePositiveInt } from "./positive-int"; +import { perStorage } from "./per-storage"; import { SingleFlight } from "./single-flight"; /** The Durable Object KV surface used by the cache. */ export type CacheKv = KvReadWrite; -/** What `KvTtlCache.partitionedBy` reads the cache authority from. */ +/** What `KvTtlCache.partitionedBy` asks for the current cache partition. */ export type AuthoritySource = { /** - * @returns The current opaque, non-secret authority covering the principal, resource scope, and - * policy, or `undefined` when unknown. + * @returns The live connection fence, or `undefined` when the source cannot vouch for one. Read + * per use, so a reconnect repartitions before the next hit rather than at the next provider call. */ - authority(): string | undefined; + cacheAuthority(): Promise; }; type CacheEntry = { @@ -23,49 +24,92 @@ type CacheEntry = { authority: string; }; +// Coalesced per storage, not per instance: a facet that builds its cache per call would +// otherwise let an older load overwrite a newer entry after the post-load fence read. +const loads = perStorage(() => new SingleFlight()); + const CACHE_PREFIX = "cache:"; +// The sigil keeps a named cache's keys out of the unnamed layout, whatever the name. +const NAMED_PREFIX = `${CACHE_PREFIX}@`; + +const CACHE_NAME = /^[A-Za-z0-9_-]+$/; + +/** + * Which keyspace a cache owns. `name` is what new code passes; `legacyUnnamed` takes the shared + * pre-kit layout, and only a port with entries already in storage should. + */ +export type CacheNamespace = + | { name: string; legacyUnnamed?: never } + | { name?: never; legacyUnnamed: true }; + /** * Durable TTL cache partitioned by authority and generation. In-flight loads are stored only when * both still match, so reconnects and invalidations cannot restore stale values. * + * Every cache needs a `name`, which gives it its own keys and generation. Two caches sharing one + * would serve each other's values for colliding `cached()` keys, and either one's + * `invalidateAll()` would clear both; `legacyUnnamed` opts into that shared layout, and only a + * port with entries already in storage should. * @example * ```ts - * #cache = KvTtlCache.partitionedBy(this.ctx.storage.kv, this.#creds); + * #cache = KvTtlCache.partitionedBy(this.ctx.storage.kv, this.#creds, { name: "projects" }); * * listProjects() { * return this.#cache.cached("projects", 60_000, - * () => this.#creds.run(creds => this.#api.listProjects(creds))); + * () => this.#creds.run(creds => this.#api.listProjects(creds), { replayable: true })); * } * ``` */ export class KvTtlCache { readonly #kv: CacheKv; - readonly #authority: () => string | undefined; - readonly #loads = new SingleFlight(); + readonly #authority: () => string | undefined | Promise; + readonly #prefix: string; /** * Creates a durable TTL cache. Authority must change on reconnect but remain stable across token * refresh; `undefined` (unknown authority) bypasses the cache entirely, since a value stored or * served without a partition could cross a reconnect. * @param kv Durable Object cache storage. - * @param authority Returns the current opaque cache partition, or `undefined` when unknown. + * @param authority Returns the current opaque cache partition, or `undefined` when unknown. May + * be synchronous, for an authority that is genuinely local. + * @param options `name` gives this cache its own keys and generation; `legacyUnnamed` takes the + * shared pre-kit layout instead. Exactly one is required. */ - constructor(kv: CacheKv, authority: () => string | undefined) { + constructor( + kv: CacheKv, + authority: () => string | undefined | Promise, + options: CacheNamespace, + ) { this.#kv = kv; this.#authority = authority; + const { name } = options; + if (name !== undefined && !CACHE_NAME.test(name)) { + throw new Error(`Cache name "${name}" must match ${CACHE_NAME.source}.`); + } + this.#prefix = name === undefined ? CACHE_PREFIX : `${NAMED_PREFIX}${name}:`; } /** - * Creates a cache partitioned by the source's authority, read per use and never captured, so the - * partition follows the source's live value and an unknown authority bypasses. For an authority - * composed of more dimensions, use the constructor; per-kind scoping belongs in key segments. + * Creates a cache partitioned by the source's live fence, read on every use and never captured. + * A hit therefore costs one account credential read — which may itself run a normal credential + * refresh — and avoids the provider request the entry exists to cache. A source that cannot vouch + * for the fetched credentials answers `undefined` and the cache bypasses rather than serving an + * entry the current principal may no longer own; a disconnected account propagates its own error + * instead. For an authority composed of more dimensions, use the constructor; per-kind scoping + * belongs in key segments. * @param kv Durable Object cache storage. * @param source Live authority to partition entries by. - * @returns A cache partitioned by the source's authority. + * @param options `name` gives this cache its own keys and generation, or `legacyUnnamed` for a + * port's existing shared layout. + * @returns A cache partitioned by the source's live connection generation. */ - static partitionedBy(kv: CacheKv, source: AuthoritySource): KvTtlCache { - return new KvTtlCache(kv, () => source.authority()); + static partitionedBy( + kv: CacheKv, + source: AuthoritySource, + options: CacheNamespace, + ): KvTtlCache { + return new KvTtlCache(kv, () => source.cacheAuthority(), options); } /** @@ -78,9 +122,9 @@ export class KvTtlCache { */ async cached(key: string, ttlMs: number, load: () => Promise): Promise { requirePositiveInt("ttlMs", ttlMs); - const authority = this.#authority(); + const authority = await this.#authority(); if (authority === undefined) return load(); - const entryKey = `${CACHE_PREFIX}entry:${key}`; + const entryKey = `${this.#prefix}entry:${key}`; const generation = this.#generation(); const entry = this.#kv.get>(entryKey); if (entry?.authority === authority && entry.generation === generation @@ -88,13 +132,25 @@ export class KvTtlCache { return entry.value; } - // Include generation and authority so stale and current callers never share a load. - const loadKey = JSON.stringify([generation, authority, key]); - return this.#loads.run(loadKey, async () => { + // Keyed by the storage entry plus generation and authority: stale and current callers never + // share a load, and two named caches over one storage never share one either. + const loadKey = JSON.stringify([entryKey, generation, authority]); + return loads(this.#kv).run(loadKey, async () => { const value = await load(); - if (this.#generation() === generation && this.#authority() === authority) { - this.#kv.put>(entryKey, - { value, fetchedAt: Date.now(), generation, authority }); + // Stamped now, not after the fence read: that read is a live account round trip, and dating + // the entry from its completion would extend the caller's TTL by however long it took. + const fetchedAt = Date.now(); + let current: string | undefined; + try { + current = await this.#authority(); + } catch { + // The load succeeded and its caller is owed it; an unreadable fence only blocks caching. + return value; + } + // The generation read is the last synchronous act before the write, so an `invalidateAll()` + // landing during the authority read cannot be written past. + if (this.#generation() === generation && current === authority) { + this.#kv.put>(entryKey, { value, fetchedAt, generation, authority }); } return value; }); @@ -102,11 +158,11 @@ export class KvTtlCache { /** Invalidates every cached entry by advancing the shared generation. */ invalidateAll(): void { - this.#kv.put(`${CACHE_PREFIX}generation`, this.#generation() + 1); + this.#kv.put(`${this.#prefix}generation`, this.#generation() + 1); } /** @returns The current cache generation. */ #generation(): number { - return this.#kv.get(`${CACHE_PREFIX}generation`) ?? 0; + return this.#kv.get(`${this.#prefix}generation`) ?? 0; } } diff --git a/packages/gatekeeper-kit/src/connect-handshake.ts b/packages/gatekeeper-kit/src/connect-handshake.ts index b9570dd9b8..baa521a46e 100644 --- a/packages/gatekeeper-kit/src/connect-handshake.ts +++ b/packages/gatekeeper-kit/src/connect-handshake.ts @@ -7,13 +7,10 @@ import { OAUTH_NONCE_LIFETIME_MS, type TimedNonce, } from "./connect-nonce"; +import type { KvMutable } from "./kv"; /** The Durable Object KV surface this module needs. */ -export type ConnectNonceKv = { - get(key: string): T | undefined; - put(key: string, value: T): void; - delete(key: string): void; -}; +export type ConnectNonceKv = KvMutable; /** KV key holding the in-flight connect nonce. Unchanged from every current gatekeeper. */ export const NONCE_KEY = "nonce"; @@ -98,7 +95,10 @@ export function advanceToOAuth( } /** - * Claims a valid OAuth callback. + * Claims a valid OAuth callback. The claim is irrevocable: the nonce is consumed whatever happens + * next, so a consumer whose `complete()` or credential persistence fails after the provider + * exchange must roll back anything it just persisted itself — the storage shape is provider-owned, + * and a second callback with the same nonce will not arrive. * @param kv Durable Object nonce storage. * @param oauthNonce Provider-returned nonce. * @param now Current Unix time in milliseconds. diff --git a/packages/gatekeeper-kit/src/credential-expiry.ts b/packages/gatekeeper-kit/src/credential-expiry.ts index 60cf7cadff..f58be21dd3 100644 --- a/packages/gatekeeper-kit/src/credential-expiry.ts +++ b/packages/gatekeeper-kit/src/credential-expiry.ts @@ -96,7 +96,9 @@ async function notify( } /** - * Re-arms the credential-expiry latch. Both writes must stay adjacent and awaitless so the arm and + * Re-arms the credential-expiry latch. For a hand-written account only: `CredentialCoordinator` + * calls this from its own commit, so every credential replacement it makes — connect, refresh, + * rejection heal — re-arms already. Both writes must stay adjacent and awaitless so the arm and * latch commit together. * @param kv Stable Durable Object expiry-latch storage. */ diff --git a/packages/gatekeeper-kit/src/credentials.ts b/packages/gatekeeper-kit/src/credentials.ts index d9db12aead..777aaa4641 100644 --- a/packages/gatekeeper-kit/src/credentials.ts +++ b/packages/gatekeeper-kit/src/credentials.ts @@ -2,6 +2,7 @@ import { createLogger } from "@gadgets/backend-utils/logger"; import { ACCESS_TOKEN_SAFETY_MS, generateNonce } from "./connect-nonce"; +import { clearCredentialExpiryLatch } from "./credential-expiry"; import type { KvMutable } from "./kv"; import { perStorage } from "./per-storage"; import { SingleFlight } from "./single-flight"; @@ -14,36 +15,157 @@ const logger = createLogger<{ vendorId: string }>({ component: "gatekeeper.crede */ export type CredentialsKv = KvMutable; +/** + * Base for errors crossing the account RPC boundary. The mark is written to both `name` and a + * transport-stable `code` (an enumerable own prop), so it survives hops that rebuild the error + * and strip `name`. + */ +abstract class MarkedError extends Error { + readonly code: string; + + /** + * Creates a marked error. + * @param mark Discriminator written to both `name` and `code`. + * @param message Display-safe message. + * @param options Optional error cause. + */ + constructor(mark: string, message: string, options?: { cause?: unknown }) { + super(message, options); + this.code = mark; + this.name = mark; + } +} + /** Provider-confirmed grant expiry. Transport and service failures must use their original errors. */ -export class CredentialsExpiredError extends Error { +export class CredentialsExpiredError extends MarkedError { /** * Creates a confirmed-expiry error. * @param message Display-safe expiry message. * @param options Optional error cause. */ constructor(message: string, options?: { cause?: unknown }) { - super(message, options); - this.name = "CredentialsExpiredError"; + super("CredentialsExpiredError", message, options); + } +} + +/** + * Credentials replaced while an operation was in flight: the rejection the operation saw was + * stale, nothing was adjudicated against the account, and the caller retries by re-entering. + */ +export class CredentialsChangedError extends MarkedError { + /** + * Creates a retryable mid-operation replacement error. + * @param options Optional error cause — typically the stale provider rejection. + */ + constructor(options?: { cause?: unknown }) { + super("CredentialsChangedError", + "This account's credentials changed during the operation; retry it.", options); + } +} + +/** + * A connect completion lost its race: the connection it started under was replaced by a revoke or + * a newer reconnect while the provider token exchange was in flight, so the mint was **not** + * stored. + * + * The caller owns that orphaned mint and should dispose of it — subject to the same caution as + * `discardMint`: revoke it only where doing so cannot invalidate the grant the winning connection + * now uses. + */ +export class ConnectionSupersededError extends MarkedError { + /** + * Creates a superseded-connection error. + * @param options Optional error cause. + */ + constructor(options?: { cause?: unknown }) { + super("ConnectionSupersededError", + "This account was reconnected or disconnected while the connect flow was completing; " + + "the credentials it produced were discarded. Start the connection again.", options); } } -/** Matches confirmed expiry by name, which survives the RPC boundary where the class does not. */ -function isExpiredError(error: unknown): boolean { - return error instanceof Error && error.name === "CredentialsExpiredError"; +/** @returns Whether the error carries the mark as its `name` or its transport-surviving `code`. */ +function marked(error: unknown, mark: string): boolean { + return error instanceof Error + && (error.name === mark || (error as { code?: unknown }).code === mark); } +/** + * Matches confirmed expiry by `name` or `code`: the class never survives RPC, and a transport that + * rebuilds errors (capnweb) keeps enumerable own props but not the name. + * @param error Caught error. + * @returns Whether the error is a confirmed credential expiry. + */ +export function isCredentialsExpired(error: unknown): boolean { + return marked(error, "CredentialsExpiredError"); +} + +/** + * Matches a retryable mid-operation credential replacement by `name` or `code`: the class never + * survives RPC, and a transport that rebuilds errors (capnweb) keeps enumerable own props but not + * the name. + * @param error Caught error. + * @returns Whether the error marks the operation retryable. + */ +export function isCredentialsChanged(error: unknown): boolean { + return marked(error, "CredentialsChangedError"); +} + +/** + * Matches a connect completion that lost its race, by `name` or `code`. The credentials it minted + * were not stored, so the caller still owns them. + * @param error Caught error. + * @returns Whether the connection was replaced mid-completion. + */ +export function isConnectionSuperseded(error: unknown): boolean { + return marked(error, "ConnectionSupersededError"); +} + +/** + * The account's adjudication of a reported credential rejection. + * - `"expired"` — the grant is gone: provider-confirmed death, or a disconnect discovered during + * the adjudication. The account owns announcing a death to the Workshop — a disconnect is a user + * action and never notifies — and the verdict never adjudicates that delivery. + * - `"superseded"` — a live successor replaced the rejected identity: a refresh, a heal inside the + * ask, or a reconnect. The failure was stale, so the caller retries or re-enters. + * - `"unavailable"` — the heal failed for non-credential reasons; nothing was adjudicated, and the + * consumer surfaces the caller's original provider error. + */ +export type RejectionVerdict = (typeof REJECTION_VERDICTS)[number]; + +const REJECTION_VERDICTS = ["expired", "superseded", "unavailable"] as const; + // Shared storage layout for kit-managed credentials. const CREDENTIALS_KEY = "credentials"; const IDENTITY_KEY = `${CREDENTIALS_KEY}:identity`; const MIGRATED_KEY = `${CREDENTIALS_KEY}:migrated`; const CONNECTION_KEY = `${CREDENTIALS_KEY}:connection`; +// One identity: the fence of the grant the provider confirmed dead, or absent for no death. +const EXPIRED_IDENTITY_KEY = `${CREDENTIALS_KEY}:expired`; const OWNED_KEYS: readonly string[] = - [CREDENTIALS_KEY, IDENTITY_KEY, MIGRATED_KEY, CONNECTION_KEY]; + [CREDENTIALS_KEY, IDENTITY_KEY, MIGRATED_KEY, CONNECTION_KEY, EXPIRED_IDENTITY_KEY]; + +const EXPIRED_MESSAGE = "This account's credentials have expired."; // Coalesce refreshes across coordinators sharing the same storage object. const refreshes = perStorage(() => new SingleFlight()); +/** + * Refreshes credentials at the provider. + * + * Must return the **complete** canonical record, not the provider's response. Providers routinely + * omit values that did not change — an unchanged rotating refresh token, granted scopes, provider + * metadata — and the coordinator replaces the stored record wholesale, so anything absent is lost + * and the *next* refresh fails after the first successful rotation. Merge from `current`: + * `{ ...current, ...response, refreshToken: response.refreshToken ?? current.refreshToken }`. + * + * Throw `CredentialsExpiredError` only when the provider proves the grant is dead. + * @param current The stored grant being refreshed. + * @returns The complete replacement record. + */ +export type RefreshCredentials = (current: Creds) => Promise; + /** Provider-specific expiry and migration policy. */ export type CredentialCoordinatorOptions = { /** @@ -63,15 +185,34 @@ export type CredentialCoordinatorOptions = { * @returns Legacy credentials, or `undefined` when absent. */ upgrade?(kv: Pick): Creds | undefined; + /** + * Disposes of a provider mint that lost its identity fence -- a reconnect or revoke won while + * the refresh was in flight -- and will never be stored. Revoke it provider-side, but only where + * revoking the discarded mint cannot invalidate the grant the surviving connection uses: RFC 7009 + * lets a provider treat revocation of one refresh token as revocation of the whole authorization + * grant, so where a reconnect reuses one grant per (user, client) the disposal would kill the + * connection that just won. For such a provider omit `discardMint` entirely and order refresh + * against connect and clear in the account itself -- the kit supplies no primitive for that. + * Errors are logged, never rethrown. + * @param mint Credentials the coordinator is dropping. + */ + discardMint?(mint: Creds): void | Promise; + /** Vendor id for log attribution. */ + vendorId?: string; }; /** * Owns credential storage, migration, and skew-aware refresh. Concurrent refreshes share one - * provider request. A crash after provider-side token rotation may still require reconnection. + * provider request, and a mint a reconnect or revoke overtook goes to `discardMint` for + * provider-side disposal. A provider-confirmed death is recorded against its identity fence, so + * every later read refuses that grant until a reconnect replaces it, while `stored()` keeps + * serving it to account-owned revoke. A crash after provider-side token rotation may still + * require reconnection. */ export class CredentialCoordinator { readonly #kv: CredentialsKv; readonly #options: CredentialCoordinatorOptions; + readonly #logger: typeof logger; /** * Creates a credential coordinator. @@ -81,6 +222,7 @@ export class CredentialCoordinator { constructor(kv: CredentialsKv, options: CredentialCoordinatorOptions = {}) { this.#kv = kv; this.#options = options; + this.#logger = options.vendorId ? logger.with({ vendorId: options.vendorId }) : logger; for (const key of options.legacyKeys ?? []) { if (OWNED_KEYS.includes(key)) { throw new Error(`Legacy key "${key}" is one the coordinator owns.`); @@ -117,22 +259,47 @@ export class CredentialCoordinator { // Canonical record first, legacy keys second. Both land in one implicit transaction, so a // machine failure takes neither; the order is what makes a throw between them survivable, since - // the grant is already readable under its new key before the old one goes away. - this.#commit(upgraded); + // the grant is already readable under its new key before the old one goes away. Publishes + // rather than commits: moving a grant between layouts replaces nothing, so a death this + // account already announced must stay latched. + this.#publish(upgraded); this.#reap(); return upgraded; } - /** @returns The opaque identity of the current credential value. */ + /** + * @returns The opaque identity of the current credential value, or `""` when never connected. + * That value is reserved for a never-connected read: it always adjudicates `"superseded"`, so a + * hand-written `getCredentials` must never serve credentials under it. + */ identity(): string { return this.#kv.get(IDENTITY_KEY) ?? ""; } /** - * Installs credentials from a connect flow. + * Installs credentials from a connect flow, rotating the connection generation. + * + * Pass `ifGeneration` to fence the asynchronous window a connect flow cannot avoid: `claimOAuth` + * consumes its nonce *before* the provider token exchange, so a revoke or a newer reconnect can + * land while that exchange is in flight, and an unfenced write lets the older completion + * overwrite it. Capture `connectionGeneration()` when the attempt starts — `advanceToOAuth` + * takes arbitrary metadata for exactly this, and `claimOAuth` hands it back — and this call + * throws `ConnectionSupersededError` rather than storing a mint the account has moved past. + * + * Fencing is opt-in because an account with no such window (a pasted token, a form submission + * with no round trip) has nothing to fence, and would then have to invent a generation to pass. * @param credentials New credentials. + * @param options `ifGeneration` refuses the write unless the connection is still the one the + * attempt started under. + * @throws `ConnectionSupersededError` when `ifGeneration` no longer matches. The credentials are + * not stored, and disposing of them is the caller's to do. */ - connect(credentials: Creds): void { + connect(credentials: Creds, options: { ifGeneration?: string } = {}): void { + const { ifGeneration } = options; + // Read and compare with no await between them and the write, so nothing can land inside. + if (ifGeneration !== undefined && this.connectionGeneration() !== ifGeneration) { + throw new ConnectionSupersededError(); + } this.#kv.put(CONNECTION_KEY, generateNonce()); this.#commit(credentials); } @@ -147,14 +314,29 @@ export class CredentialCoordinator { } /** - * Publishes credentials behind a new identity fence. + * Publishes credentials behind a new identity fence. Fence first: a torn write may only lie + * toward `"superseded"` (one doomed retry), never leave a stale identity fronting fresh + * credentials, where the identity match gating `"expired"` would falsely retire a live grant. * @param credentials Credentials to store. */ - #commit(credentials: Creds): void { + #publish(credentials: Creds): void { this.#supersede(); this.#kv.put(CREDENTIALS_KEY, credentials); } + /** + * Publishes replacement credentials and re-arms the expiry latch, so the next confirmed death + * notifies again and a notification still in flight for the credentials this replaces cannot + * latch these. + * @param credentials Credentials to store. + */ + #commit(credentials: Creds): void { + // Latch first: both puts land in one implicit transaction, and a split fails toward a + // duplicate notice this way round rather than toward silence. + clearCredentialExpiryLatch(this.#kv); + this.#publish(credentials); + } + /** Clears credentials and prevents legacy migration from restoring them. */ clear(): void { this.#kv.put(MIGRATED_KEY, true); @@ -183,12 +365,32 @@ export class CredentialCoordinator { } } + /** + * @param identity Identity fence to test. + * @returns Whether the account recorded that grant's confirmed death. + */ + #isExpired(identity: string): boolean { + return this.#kv.get(EXPIRED_IDENTITY_KEY) === identity; + } + + /** + * Records a confirmed grant death, so later reads -- and every other facet over this storage -- + * refuse the grant instead of rediscovering its death at the provider. One scalar: a fence the + * account moved makes the old marker inert, and the next death overwrites it. + * @param identity Fence of the grant the provider confirmed dead; a stale one marks nothing. + */ + #markExpired(identity: string): void { + if (identity === this.identity() && !this.#isExpired(identity)) { + this.#kv.put(EXPIRED_IDENTITY_KEY, identity); + } + } + /** * Returns usable credentials, refreshing after the expiry boundary. - * @param refresh Provider refresh operation. + * @param refresh Provider refresh, under the `RefreshCredentials` contract. * @returns Current or refreshed credentials. */ - async fresh(refresh: (current: Creds) => Promise): Promise { + async fresh(refresh: RefreshCredentials): Promise { const current = this.#connected(); const expiresAt = this.#options.expiresAt?.(current); if (expiresAt !== undefined && !Number.isFinite(expiresAt)) { @@ -201,27 +403,30 @@ export class CredentialCoordinator { /** * Refreshes credentials immediately. - * @param refresh Provider refresh operation. + * @param refresh Provider refresh, under the `RefreshCredentials` contract. * @returns Current or refreshed credentials. */ - async rotate(refresh: (current: Creds) => Promise): Promise { + async rotate(refresh: RefreshCredentials): Promise { return this.#coalesced(this.#connected(), refresh); } - /** @returns Stored credentials, or throws when disconnected. */ + /** @returns Stored credentials, or throws when disconnected or the grant is recorded dead. */ #connected(): Creds { const current = this.stored(); if (current === undefined) throw new CredentialsExpiredError("This account is not connected."); + // Death outlives the call that found it, so an access token still inside its own expiry + // window is refused too. `stored()` stays open, so revoke keeps its material. + if (this.#isExpired(this.identity())) throw new CredentialsExpiredError(EXPIRED_MESSAGE); return current; } /** * Coalesces refreshes behind the current identity fence. * @param current Credentials being refreshed. - * @param refresh Provider refresh operation. + * @param refresh Provider refresh, under the `RefreshCredentials` contract. * @returns Current, refreshed, or concurrently replaced credentials. */ - #coalesced(current: Creds, refresh: (current: Creds) => Promise): Promise { + #coalesced(current: Creds, refresh: RefreshCredentials): Promise { // Keyed by the identity fence, so a caller arriving after a reconnect starts its own refresh // rather than riding one whose result is already fenced out. const fence = this.identity(); @@ -232,57 +437,256 @@ export class CredentialCoordinator { * Runs one fenced provider refresh. * @param current Credentials being refreshed. * @param fence Identity captured before refresh. - * @param refresh Provider refresh operation. + * @param refresh Provider refresh, under the `RefreshCredentials` contract. * @returns Refreshed credentials unless a newer connection won. */ async #refresh( current: Creds, fence: string, - refresh: (current: Creds) => Promise, + refresh: RefreshCredentials, ): Promise { let refreshed: Creds; try { refreshed = await refresh(current); } catch (error) { - if (!(error instanceof CredentialsExpiredError) || this.identity() === fence) throw error; - return this.#overtaken(error); + if (!isCredentialsExpired(error)) throw error; + // A stale failure adjudicates nothing; only the fence it ran under may be buried. + if (this.identity() !== fence) return this.#overtaken(error); + this.#markExpired(fence); + throw error; } - if (this.identity() !== fence) return this.#overtaken(); + // Fenced out, or buried while the mint was in flight: either way it will never be stored, so + // the provider is told to drop it. + if (this.identity() !== fence || this.#isExpired(fence)) { + await this.#discard(refreshed); + return this.#overtaken(); + } this.#commit(refreshed); return refreshed; } /** - * Resolves a refresh overtaken by reconnect or revoke. + * Resolves a refresh overtaken by reconnect, revoke, or a death recorded while it ran. * @param cause Optional expiry error from the stale refresh. - * @returns Replacement credentials, or throws when disconnected. + * @returns Replacement credentials, or throws when disconnected or the successor is dead. */ #overtaken(cause?: unknown): Creds { const latest = this.stored(); - if (latest !== undefined) return latest; - throw new CredentialsExpiredError("This account was disconnected while refreshing.", { cause }); + if (latest === undefined) { + throw new CredentialsExpiredError( + "This account was disconnected while refreshing.", { cause }); + } + if (this.#isExpired(this.identity())) { + throw new CredentialsExpiredError(EXPIRED_MESSAGE, { cause }); + } + return latest; + } + + /** + * Hands a fenced-out mint to the provider-side disposal seam. + * @param mint Credentials that will never be stored. + */ + async #discard(mint: Creds): Promise { + try { + await this.#options.discardMint?.(mint); + } catch (error) { + this.#logger.warn("discarded mint handler failed", { + event: "credentials.mint.discard.failed", + error, + }); + } + } + + /** + * Reads the credential triple the account RPC surface serves: current credentials, their + * identity fence, and their connection generation. The three reads are synchronous after the + * refresh settles — no await between them — so a `connect()` landing at the await boundary + * cannot tear the triple apart. That atomicity is why the helper lives on the coordinator; a + * hand-written `getCredentials` owns it itself. The triple carries the stored grant: a surface + * whose public credentials differ projects `creds` before returning, so refresh material never + * crosses the RPC boundary. + * @param refresh Provider refresh, under the `RefreshCredentials` contract. + * @param options `notify` announces confirmed grant death to the Workshop before the rethrow. + * @returns Current credentials with their identity and connection generation. + * @throws `CredentialsExpiredError` on confirmed expiry, after awaiting `notify` when the dead + * grant is still stored — a disconnect is a user action, not grant death, and never notifies. + * A reconnect landing while `notify` is pending replaces the death: the fresh triple is served. + * A disconnect landing there reads as not connected, carrying the death as its cause. + */ + async snapshot( + refresh: RefreshCredentials, + options: { notify?: () => Promise } = {}, + ): Promise> { + try { + await this.fresh(refresh); + } catch (error) { + if (!isCredentialsExpired(error) || this.stored() === undefined + || options.notify === undefined) throw error; + // This reads the dead grant's own fence: only microtasks separate it from `#refresh`'s + // `identity() === fence` check, and a `connect()` arrives on an I/O turn. A reconnect + // landing mid-notify replaced the dead grant: serve it instead of stale death. + if (await this.#notified(this.identity(), options.notify)) throw error; + // A disconnect landing there moves the fence too; keep the death's provenance. + if (this.stored() === undefined) { + throw new CredentialsExpiredError("This account is not connected.", { cause: error }); + } + } + const creds = this.#connected(); + return { creds, identity: this.identity(), generation: this.connectionGeneration() }; + } + + /** + * Adjudicates a consumer-reported credential rejection, healing past a rejected-but-current + * credential inside the ask. The verdict adjudicates the identity, never notification delivery, + * which the account owns end to end. Invariants a hand-written implementation owns instead: + * the moved-past gate (`""` never matches), the heal fenced on the rejected identity, and + * honest verdicts — `"superseded"` only under a live successor and `"expired"` for a dead or + * disconnected grant, the fence re-checked after the notify await since a reconnect landing + * mid-notification supersedes it. + * + * Death is durable and notification is not: the marker retires the identity for every facet at + * once, while `notifyCredentialsExpiredOnce`'s latch keeps its own retry, so a later read + * refuses the grant without another mint but can still deliver an announcement that failed. + * @param identity Credential identity the consumer saw rejected. + * @param options `refresh` mints past a stale credential under the `RefreshCredentials` contract + * (grant-death providers leave it unset); + * `notify` announces confirmed grant death to the Workshop. + * @returns The verdict on the rejected identity. + */ + async adjudicateRejection( + identity: string, + options: { refresh?: RefreshCredentials; notify: () => Promise }, + ): Promise { + // "" — a never-connected read — must not match a never-connected account's own "". + if (identity === "") return "superseded"; + // Moved-past gate: whatever moved the fence already adjudicated the rejected identity. + if (identity !== this.identity()) return this.#moved(); + // A grant-death provider has no mint to heal with: the rejection is the grant's death. + if (options.refresh === undefined) { + this.#markExpired(identity); + return this.#expired(identity, options.notify); + } + try { + // Fence-keyed, so concurrent heals of one identity collapse onto one provider mint. + await this.rotate(options.refresh); + // The commit rotated the fence — or a reconnect overtook the mint. Either way the rejected + // identity is no longer current. + return "superseded"; + } catch (error) { + // A reconnect or disconnect landing while the mint failed wins whatever the mint died of — + // logged, since this branch is the mint error's only account-side trace. + if (this.identity() !== identity) { + this.#logger.warn("credential rejection heal overtaken", { + event: "credentials.rejection.heal.overtaken", + error, + }); + return this.#moved(); + } + if (isCredentialsExpired(error)) return this.#expired(identity, options.notify); + // Non-credential mint failure: nothing adjudicated, credentials intact. The consumer + // surfaces the caller's original provider error; the token endpoint's lives in this log. + this.#logger.error("credential rejection heal failed", { + event: "credentials.rejection.heal.failed", + error, + }); + return "unavailable"; + } + } + + /** + * Resolves a confirmed grant death into its verdict. + * @param identity The dead grant's identity fence. + * @param notify Announces the grant death to the Workshop. + * @returns `"expired"`, or the moved-fence verdict when the fence moved mid-notify. + */ + async #expired(identity: string, notify: () => Promise): Promise { + return await this.#notified(identity, notify) ? "expired" : this.#moved(); + } + + /** + * Resolves a rejected identity the fence moved past. `"superseded"` promises a *live* successor, + * so a fence moved by a disconnect, or onto a grant this account has since buried, answers + * `"expired"` instead: the caller reconnects rather than re-entering into credentials that + * cannot work. The disconnect itself never notifies — a user action. + * @returns `"superseded"` under a live successor, `"expired"` otherwise. + */ + #moved(): RejectionVerdict { + return this.stored() === undefined || this.#isExpired(this.identity()) + ? "expired" + : "superseded"; + } + + /** + * Awaits a Workshop notification, then re-checks the identity fence. + * @param identity Identity fence captured when the death was decided. + * @param notify Announces the grant death to the Workshop; a failure is logged, never masking + * the verdict. + * @returns Whether `identity` survived the await — a reconnect landing mid-notify moves the + * fence, so a death decided before the notification no longer stands. + */ + async #notified(identity: string, notify: () => Promise): Promise { + try { + await notify(); + } catch (error) { + this.#logger.warn("failed to notify credential expiry", { + event: "credentials.expiry.notify.failed", + error, + }); + } + return this.identity() === identity; } } /** One fetch of credentials, tagged with their identity and connection generation. */ -export type CredentialsWithIdentity = - { creds: Creds; identity: string; generation: string }; +export type CredentialsWithIdentity = CredentialRead & { creds: Creds }; + +/** + * The identity and generation of the read a `run` operation executes under — the values to + * capture in an action fence, since a retry runs under a different read than the first attempt and + * shared source state can move mid-operation. A fresh object per attempt, never the source's + * internal state. An identity of `""` is reserved for a never-connected read: it always + * adjudicates `"superseded"`, and no read serving credentials may carry it. + */ +export type CredentialRead = { identity: string; generation: string }; -/** Account-side RPC shape. See `CredentialSourceOptions.account` for stub ownership. */ +/** + * Account-side RPC shape. See `CredentialSourceOptions.account` for stub ownership. The contract + * is this structural interface; the coordinator helpers are the reference implementation, and an + * account with esoteric needs — per-endpoint connections, custom storage — hand-writes either + * method in plain TS and owns its invariants instead. + */ export type AccountCredentialStub = { /** - * Reads current credentials, refreshing as needed. - * @returns Current credentials, their identity fence, and their connection generation. - * @throws On confirmed expiry, an error named `CredentialsExpiredError` — the transport may strip - * the class, so the name is the contract the source drops its cache authority on. + * Reads current credentials, refreshing as needed. `CredentialCoordinator.snapshot` is the + * reference implementation; a hand-written stub owns the triple's atomicity — no credential + * change may land between the three reads. Serve the public projection of the stored grant: + * refresh material never crosses this boundary. + * @returns Current credentials, their identity fence, and their connection generation. The + * identity is never `""` — that value is reserved for a never-connected read and always + * adjudicates `"superseded"` — and the source refuses a read served under it. + * @throws On confirmed expiry, an error carrying `CredentialsExpiredError` as its `name` or + * `code` — the transport may strip the class or rebuild the name away, so those marks are the + * contract the source drops its cache authority on. */ getCredentials(): Promise>; /** - * Reports expiry when the credential identity is still current. + * Reports a provider credential rejection and answers with the account's verdict, healing past + * a rejected-but-current credential inside the ask where the provider allows a mint. + * `CredentialCoordinator.adjudicateRejection` is the reference implementation; a hand-written + * stub owns its invariants — the moved-past gate, the heal fenced on the rejected identity, and + * honest verdicts, with `"expired"` reserved for provider-confirmed grant death. * @param identity Credential identity used by the failed call. + * @returns An adjudication of identity, never of notification delivery, which the account owns + * end to end. `"superseded"` means a live successor replaced the rejected identity — a refresh, + * a heal, or a reconnect — so the failure was stale and the source resolves it as retryable; + * `"expired"` means the grant is dead or the account disconnected, with any Workshop + * notification the account's own to deliver; + * `"unavailable"` means the heal failed for non-credential reasons and nothing was adjudicated, + * so the source surfaces the caller's original provider error. A malformed or lost answer does + * the same: only the account's own word dead-marks or expires an identity. */ - noteCredentialsExpired(identity: string): Promise; + reportCredentialsRejected(identity: string): Promise; }; /** `CredentialSource` keeps one flight -- the account's current credentials -- so it needs one key. */ @@ -293,8 +697,11 @@ export type CredentialSourceOptions = { /** @returns A fresh or caller-owned account credential stub. */ account(): AccountCredentialStub; /** - * Classifies provider-confirmed credential expiry. Per-resource access denials must remain separate - * so an unauthorized request cannot disconnect a healthy account. + * Classifies credential rejection — the provider refusing the presented credentials. Per-resource + * access denials must remain separate so an unauthorized request cannot disconnect a healthy + * account. The classifier need not tell a stale derived bearer from a dead grant — the + * provider's signal is the same; the account's heal inside the rejection adjudication + * disambiguates, and only a rejection the heal cannot move past reads as expiry. * @param error Caught provider error. * @returns Whether credentials caused the failure. */ @@ -306,8 +713,11 @@ export type CredentialSourceOptions = { }; /** - * Fetches current credentials for provider operations and reports confirmed expiry. Reads coalesce - * while in flight but are not cached across operations. + * Fetches current credentials for provider operations and resolves confirmed credential rejections + * through the account's verdict. Reads coalesce while in flight but are not cached across + * operations. The source itself is optional: ports that only want coordinated storage use `get()` + * or the account stub directly, and callers wanting their own retry policy skip `replayable` and + * match the named errors (`isCredentialsChanged` / `isCredentialsExpired`) in a plain loop. * * @example * ```ts @@ -326,6 +736,7 @@ export class CredentialSource { readonly #options: CredentialSourceOptions; readonly #logger: typeof logger; readonly #fetches = new SingleFlight(); + readonly #asks = new SingleFlight(); #generation: string | undefined; #identity: string | undefined; // Bounded by account commits per activation; eviction is unsafe against out-of-order stale reports. @@ -347,52 +758,217 @@ export class CredentialSource { } /** - * The cache authority for data fetched through this source (`KvTtlCache.partitionedBy`): mirrors - * the connection generation of the last successful fetch rather than reading the account live, so - * a reconnect repartitions at the next fetch and a token refresh never does. A shared last-seen - * value a concurrent fetch can move — action-fence capture must ride the `generation` of its own - * `getCredentials()` read, never this accessor. Direct callers compose custom authorities for the - * raw cache constructor. - * @returns The last-seen connection generation; `undefined` (principal unknown) until a fetch - * succeeds, and from a reported expiry until a fetch started after the report adopts an identity - * not reported dead. + * Fetches the current read's fence data without the credentials — for action-fence capture and + * comparison outside a `run` operation, such as at action apply. + * @returns A fresh object carrying the current identity fence and connection generation. + */ + async read(): Promise { + const { identity, generation } = await this.#current(); + return { identity, generation }; + } + + /** + * Returns the live connection generation only while this source vouches for the fetched + * credentials. The account is read on every call, so reconnects are visible before a cache hit; + * a dead, pending, or fenced-out identity returns `undefined` and therefore bypasses caching. + * @returns The current cache authority, or `undefined` when this source cannot vouch for one. */ - authority(): string | undefined { - return this.#generation; + async cacheAuthority(): Promise { + const current = await this.#current(); + return this.#generation === current.generation && this.#identity === current.identity + ? current.generation + : undefined; } /** - * Runs a provider operation and reports confirmed expiry. - * @param operation Provider call using current credentials. + * Runs a provider operation, resolving a confirmed credential rejection through the account's + * verdict on the identity the operation used. The account heals past a rejected-but-current + * credential inside that ask, so recovery stays invisible here except through the verdict. + * @param operation Provider call using current credentials. Its second argument is the read the + * attempt runs under — capture action fences from it, never from state a concurrent fetch can + * move mid-operation. + * @param options `replayable` marks the operation safe to execute twice: a `"superseded"` + * verdict — the rejected credential was already replaced, or the account just healed past it — + * retries the operation once with freshly fetched credentials, as does a same-generation + * successor the source already adopted (no ask spent). Without the flag the same verdict + * throws `CredentialsChangedError` and the caller re-enters. The operation runs at most twice + * either way. * @returns The provider operation result. + * @throws `CredentialsExpiredError` (carrying the configured `expiredMessage`) on the account's + * `"expired"` verdict — the credential fetch itself may also throw one, and that carries the + * account's own message instead; `CredentialsChangedError` when the rejection was stale and re-entering + * will read live credentials; the original provider error when the failure was not a credential + * rejection, or the account could not adjudicate it — its heal failed for non-credential + * reasons, or it could not be reached at all. Both named errors match + * (`isCredentialsExpired` / `isCredentialsChanged`) across RPC boundaries — their `code` + * survives the transports that strip `name`. */ - async run(operation: (credentials: Creds) => Promise): Promise { - const { creds, identity } = await this.#current(); + async run( + operation: (credentials: Creds, read: CredentialRead) => Promise, + options: { replayable?: boolean } = {}, + ): Promise { + return this.#attempt(operation, await this.#current(), options.replayable === true); + } + + /** + * Executes one attempt under one read, resolving a credential rejection by the account's verdict. + * @param operation Provider call being attempted. + * @param read The read this attempt runs under. + * @param retry Whether a superseded rejection may retry — false on the second attempt. + * @returns The operation result. + */ + async #attempt( + operation: (credentials: Creds, read: CredentialRead) => Promise, + read: CredentialsWithIdentity, + retry: boolean, + ): Promise { try { - return await operation(creds); + // A fresh object, never the internal triple: the operation may hold or mutate its read. + return await operation(read.creds, { identity: read.identity, generation: read.generation }); } catch (error) { if (!this.#options.isAuthError(error)) throw error; - // A newer fetch adopted a live grant: this failure is stale, so that grant is neither - // reported dead nor its cache authority dropped. An adopted identity that is itself dead is - // no successor — then this failure is the freshest evidence, however old its read. - if (identity !== this.#identity - && this.#identity !== undefined && !this.#dead.has(this.#identity)) { - throw new Error("This account's credentials changed during the operation; retry it.", - { cause: error }); - } - // Drop the in-flight fetch: it was started against the credentials just reported dead, and - // leaving it would hand them to the next caller anyway. The generation goes with it, or a - // cache hit under the dead grant's partition could serve the next principal stale data. - this.#fetches.forget(CREDENTIALS_FLIGHT); - this.#generation = undefined; - this.#dead.add(identity); - this.#clearFence++; - await this.#note(identity); - throw new Error(this.#options.expiredMessage, { cause: error }); + return this.#resolve(operation, read, error, retry); + } + } + + /** + * Resolves a confirmed credential rejection by the account's verdict. + * @param operation Provider call being resolved. + * @param read The read whose credentials the provider rejected. + * @param cause Provider rejection being resolved. + * @param retry Whether a superseded verdict retries the operation instead of rethrowing. + * @returns The retried operation result, when a retry resolves it. + */ + async #resolve( + operation: (credentials: Creds, read: CredentialRead) => Promise, + read: CredentialsWithIdentity, + cause: unknown, + retry: boolean, + ): Promise { + // A newer fetch adopted a live grant: this failure is stale, so that grant is neither + // reported dead nor its cache authority dropped. The shortcut needs evidence the source + // stands behind — an adopted identity that is itself dead, or one whose authority was + // dropped, is no successor; then the account adjudicates. + if (this.#successorTo(read.identity)) { + // A successor under the read's own generation is a heal of the caller's principal, so a + // replayable operation retries under it — no ask spent, the account already moved past. A + // moved generation is a reconnect, and stays a re-entry. + if (retry && read.generation === this.#generation) return this.#retry(operation, read, cause); + throw new CredentialsChangedError({ cause }); + } + const verdict = await this.#verdict(read.identity); + if (verdict === "expired") { + throw new CredentialsExpiredError(this.#options.expiredMessage, { cause }); + } + // Nothing adjudicated: the account's heal failed for non-credential reasons, or the account + // could not answer at all (either error lives in a log, not in this path), so the caller sees + // the provider rejection it actually got. + if (verdict === "unavailable" || verdict === "unadjudicated") throw cause; + if (!retry) throw new CredentialsChangedError({ cause }); + return this.#retry(operation, read, cause); + } + + /** + * Reports a rejection and takes the account's verdict. + * @param identity Credential identity used by the failed call. + * @returns The account's verdict on that identity, or `"unadjudicated"` when it could not + * answer. + */ + async #verdict(identity: string): Promise { + // Drop at the ask: the rejection already proves this snapshot cannot vouch, whichever way + // the answer goes — dead, its partition could serve the next principal stale data on a hit; + // superseded, it no longer vouches for the current principal — so cache-first readers bypass + // during the round trip instead of serving the rejected partition. Drop again on the answer: + // a hand-written account may keep serving a dead grant until reconnect, so a read landing + // meanwhile may re-adopt it, and the death mark itself must wait for the account's word. + this.#supersede(); + // The verdict adjudicates the identity, not the report, so concurrent reporters of one grant + // share the account round trip — and the account's fence-keyed heal collapses their mints. + const answer = await this.#asks.run(identity, () => this.#note(identity)); + // A successor adopted while the ask was in flight makes this answer stale: the reported + // identity is dead-marked, but the live grant keeps the authority it just adopted. Nothing + // orders the ask's reply against a fetch's — they ride separate account stubs. + if (this.#successorTo(identity)) { + if (answer === "expired") this.#dead.add(identity); + return "superseded"; } + // An unadjudicated report never dead-marks: a transient account outage must not retire a + // possibly-live identity for the rest of the activation, nor invent an expiry the account + // never confirmed. + this.#supersede(answer === "expired" ? identity : undefined); + return answer; } - /** @returns One coalesced account credential read. */ + /** + * @param identity Identity a failure was reported under. + * @returns Whether a live successor to it has been adopted — evidence the source stands behind, + * so an adopted identity that is itself dead, or one whose authority was dropped, is no + * successor. + */ + #successorTo(identity: string): boolean { + return this.#generation !== undefined && this.#identity !== undefined + && this.#identity !== identity && !this.#dead.has(this.#identity); + } + + /** + * Retries a rejected operation once with freshly fetched credentials — after a `"superseded"` + * verdict, whose fence bump forgot the pre-ask flight so the single-threaded account answers + * the refetch after its heal's commit, or under a live successor the source already adopted. + * @param operation Provider call being retried. + * @param first The read whose rejection resolved as superseded. + * @param cause Provider rejection being resolved. + * @returns The retried operation result. + */ + async #retry( + operation: (credentials: Creds, read: CredentialRead) => Promise, + first: CredentialsWithIdentity, + cause: unknown, + ): Promise { + const second = await this.#current(); + // A moved generation is a reconnect: never run under a principal the caller didn't start + // with. The caller re-enters and fetches the new connection deliberately. + if (second.generation !== first.generation) throw new CredentialsChangedError({ cause }); + // A concurrent resolution already had this successor adjudicated dead — don't run under it. + // Only when it is the read the source last stood behind: a fenced-out refetch of a dead + // identity is stale evidence, adjudicating nothing the source stands behind now. + if (this.#dead.has(second.identity) && this.#identity === second.identity) { + throw new CredentialsExpiredError(this.#options.expiredMessage, { cause }); + } + // Retry only under the read the source itself adopted: a fenced-out or since-superseded + // refetch is stale evidence that can postdate a reconnect the source already adopted, with + // no adoption of its own to act on — checked before the same-identity supersede below. + if (this.#generation !== second.generation || this.#identity !== second.identity) { + throw new CredentialsChangedError({ cause }); + } + // "Superseded" promised a successor; the same identity back means a lazy account re-served + // the credentials the provider already rejected. Re-entering is honest — retrying would burn + // the one retry proving nothing — and the refetch's adoption is undone: a just-rejected + // credential cannot keep vouching for the cache partition. + if (second.identity === first.identity) { + this.#supersede(); + throw new CredentialsChangedError({ cause }); + } + // At most two attempts: a second rejection is adjudicated but never retried again. + return this.#attempt(operation, second, false); + } + + /** + * Drops the cache authority and fences out account reads started before now — the in-flight one + * included — so neither can overwrite what this source just learned. + * @param dead Identity to stop adopting after its confirmed expiry. + */ + #supersede(dead?: string): void { + if (dead !== undefined) this.#dead.add(dead); + this.#generation = undefined; + this.#clearFence++; + this.#fetches.forget(CREDENTIALS_FLIGHT); + } + + /** + * Runs one coalesced account credential read, adopting its identity and cache authority unless + * fenced out. + * @returns The fetched credentials. + */ async #current(): Promise> { const fence = this.#clearFence; let current: CredentialsWithIdentity; @@ -402,13 +978,28 @@ export class CredentialSource { } catch (error) { // A fetch rejecting with confirmed expiry (a failed refresh) reports the grant as dead as a // 401 does. Fenced like adoption: a straggler's stale rejection must not clear a revival. - if (fence === this.#clearFence && isExpiredError(error)) this.#generation = undefined; + if (fence === this.#clearFence && isCredentialsExpired(error)) this.#generation = undefined; throw error; } - // Dual guard, neither subsumes the other: the fence blocks fetches started before an expiry + // "" is reserved for a never-connected read: adopting live credentials under it would wedge + // every rejection as retryable, since "" always adjudicates superseded. + if (current.identity === "") { + // Fail closed, but only over state this read still owns: fenced like adoption and like the + // rejection above, so a straggler's malformed answer cannot clear a revival that overtook it. + if (fence === this.#clearFence) this.#supersede(); + throw new Error( + 'The account served credentials under the reserved "" identity; ' + + "getCredentials must fence every read."); + } + // Three guards, none subsuming another: the fence blocks fetches started before an expiry // report (a straggler can carry any old identity, not just a marked one), the dead set blocks - // the grants the account keeps serving after their reports. - if (fence === this.#clearFence && !this.#dead.has(current.identity)) { + // any grant an account keeps serving after its report, and the pending ask blocks a + // post-report fetch handing the rejected partition back before the verdict — cache-first + // readers bypass for the whole round trip. The fence holds even against a read resolving a + // reconnect: generations are opaque and equality-only, so a fenced response cannot prove + // itself newest — authority stays the last unfenced fetch. + if (fence === this.#clearFence && !this.#dead.has(current.identity) + && !this.#asks.pending(current.identity)) { this.#generation = current.generation; this.#identity = current.identity; } @@ -416,17 +1007,28 @@ export class CredentialSource { } /** - * Reports expiry without replacing the provider error. + * Reports a rejection without replacing the provider error. * @param identity Credential identity used by the failed call. + * @returns The account's verdict, or `"unadjudicated"` for an unreachable account or a + * malformed answer — which the caller surfaces as the provider error it already had, since only + * the account's own word may dead-mark or expire an identity. */ - async #note(identity: string): Promise { + async #note(identity: string): Promise { + let verdict: RejectionVerdict; try { - await this.#options.account().noteCredentialsExpired(identity); + verdict = await this.#options.account().reportCredentialsRejected(identity); } catch (error) { - this.#logger.error("failed to report credential expiry", { - event: "credentials.expiry.report.failed", + this.#logger.error("failed to report credential rejection", { + event: "credentials.rejection.report.failed", error, }); + return "unadjudicated"; } + if (REJECTION_VERDICTS.includes(verdict)) return verdict; + this.#logger.error("malformed credential rejection verdict", { + event: "credentials.rejection.verdict.malformed", + error: new Error(`unexpected verdict type: ${typeof verdict}`), + }); + return "unadjudicated"; } } diff --git a/packages/gatekeeper-kit/src/cursors.ts b/packages/gatekeeper-kit/src/cursors.ts index 99ceefee0c..692a852eb3 100644 --- a/packages/gatekeeper-kit/src/cursors.ts +++ b/packages/gatekeeper-kit/src/cursors.ts @@ -31,11 +31,43 @@ export class ArrayCursor extends RpcTarget implements Cursor { } } -type CursorShape = { +type CursorShape = { /** How many items each `next()` returns. */ pageSize: number; /** How many items to ask the provider for at a time. */ remotePageSize?: number; + /** + * Releases resources the fetch callback owns — a duplicated RPC stub, most often — when the + * cursor is disposed. Without it a fetch callback may only borrow stubs the session owns for at + * least as long as the walk: dropping the cursor stub would otherwise leak whatever the callback + * duplicated for itself. Return a cursor to exactly one RPC call: capnweb disposes the target + * once per stub, so the first drop of a shared cursor would release the walk another still uses. + */ + dispose?(): void; + /** + * Authorizes what `next()` is about to return, before it leaves the cursor. Runs on every page, + * including one served entirely from the buffer with no provider fetch, and once for a walk that + * ends having disclosed nothing — a zero-result query answers "no such thing", which is provider + * data too. + * + * A throw holds the outgoing page, so the retry re-offers exactly it, with no further provider + * fetch and no chance of a capped page growing between refusal and retry. The exception is an + * empty page from a spent window: nothing was disclosed, so the retry opens a fresh window + * rather than pinning the walk on a failure that may have been transient. + * + * That hold is why a walk pinned to a connection must re-check its authority **here**, not only + * in its fetch callback: the retry path never re-enters the fetch, so a reconnect landing + * between refusal and retry would otherwise disclose the previous connection's rows. + * + * `terminal` marks the walk over, so `items` is empty and no further page will come. Describe + * that case as the query it answered rather than the rows it returned, and give it a + * `{ kind: "baseline" }` scope or a synthetic collection id: the gate refuses a `collections` + * scope naming none. A mid-walk empty page (a spent fetch window that still says "ask again") + * arrives with `terminal: false`. + * @param items The page `next()` is about to return; empty when `terminal`. + * @param context `terminal` when this ends the walk. + */ + authorizePage(items: readonly T[], context: { terminal: boolean }): Promise; }; // Bound sequential requests per `next()`; an empty visibility window returns `[]`, not exhaustion. @@ -47,22 +79,49 @@ const DEFAULT_REMOTE_PAGE_SIZE = 100; const loadMore = Symbol("loadMore"); // Shared buffered implementation for provider-backed cursors. -abstract class BufferedCursor extends RpcTarget implements Cursor { +abstract class BufferedCursor extends RpcTarget implements Cursor, Disposable { readonly #pageSize: number; readonly #queue = new SerialTaskQueue(); + readonly #dispose?: () => void; + readonly #authorizePage: (items: readonly T[], context: { terminal: boolean }) => Promise; + // Set once the walk has either disclosed rows or authorized that it had none. An empty window + // leaves it clear: that page answered nothing, so the terminal answer is still owed. + #answered = false; + #pending?: T[]; + #disposed = false; protected readonly remotePageSize: number; protected readonly buffer: T[] = []; protected remoteExhausted = false; /** * Creates a buffered provider cursor. - * @param options Local and provider page sizes. + * @param options Local and provider page sizes, page authorization, and an optional release hook. */ - constructor(options: CursorShape) { + constructor(options: CursorShape) { super(); - this.#pageSize = requirePositiveInt("pageSize", options.pageSize); - this.remotePageSize = - requirePositiveInt("remotePageSize", options.remotePageSize ?? DEFAULT_REMOTE_PAGE_SIZE); + this.#dispose = options.dispose; + this.#authorizePage = options.authorizePage; + // Assigned first, so a rejected page size releases what the caller already acquired for this + // cursor -- the documented pattern leases a gate before constructing one. + try { + this.#pageSize = requirePositiveInt("pageSize", options.pageSize); + this.remotePageSize = + requirePositiveInt("remotePageSize", options.remotePageSize ?? DEFAULT_REMOTE_PAGE_SIZE); + } catch (error) { + this[Symbol.dispose](); + throw error; + } + } + + /** + * Releases what the fetch callback owns. Idempotent, since the runtime may dispose a target a + * second reference already released. A `next()` after disposal is the callback's own business: + * whatever it borrowed or released decides what that call does. + */ + [Symbol.dispose](): void { + if (this.#disposed) return; + this.#disposed = true; + this.#dispose?.(); } /** Loads the next provider page into the buffer. */ @@ -75,20 +134,38 @@ abstract class BufferedCursor extends RpcTarget implements Cursor { /** @returns One local page, `[]` when the fetch window is spent, or `null` at exhaustion. */ async #fill(): Promise { - let pages = 0; - while (this.buffer.length < this.#pageSize - && !this.remoteExhausted - && pages++ < MAX_PROVIDER_PAGES_PER_CALL) { - await this[loadMore](); + // A refused page is held, so the retry re-offers exactly it. Refilling instead would grow a + // page the provider had capped, changing what the approver already refused. An empty window + // is not held: it disclosed nothing, and pinning it would stall the walk on a lost reply. + if (!this.#pending?.length) { + let pages = 0; + while (this.buffer.length < this.#pageSize + && !this.remoteExhausted + && pages++ < MAX_PROVIDER_PAGES_PER_CALL) { + await this[loadMore](); + } + // Only exhaustion ends the walk. A spent window yields `[]`, which says "ask again". + if (this.buffer.length === 0 && this.remoteExhausted) { + // A walk that disclosed nothing still answered the query: "no such thing" is provider + // data. Authorized once, so a repeated terminal `next()` emits no duplicate observation. + if (!this.#answered) { + await this.#authorizePage([], { terminal: true }); + this.#answered = true; + } + return null; + } + this.#pending = this.buffer.splice(0, this.#pageSize); } - // Only exhaustion ends the walk. A spent window yields `[]`, which says "ask again". - if (this.buffer.length === 0 && this.remoteExhausted) return null; - return this.buffer.splice(0, this.#pageSize); + const page = this.#pending; + await this.#authorizePage(page, { terminal: false }); + this.#pending = undefined; + if (page.length > 0) this.#answered = true; + return page; } } /** Options for a provider that pages by page number. */ -export type PageNumberCursorOptions = CursorShape & { +export type PageNumberCursorOptions = CursorShape & { /** * Fetches one unfiltered provider page. Filter in `retain`, or a fully hidden page would end the * walk. @@ -106,7 +183,7 @@ export type PageNumberCursorOptions = CursorShape & { }; /** Options for a provider that pages by numeric offset. */ -export type OffsetCursorOptions = CursorShape & { +export type OffsetCursorOptions = CursorShape & { /** * Fetches one unfiltered provider page. Filter in `retain`, or a fully hidden page would end the * walk. @@ -198,7 +275,7 @@ export type TokenPage = { }; /** Options for a provider that pages by continuation token. */ -export type TokenCursorOptions = CursorShape & { +export type TokenCursorOptions = CursorShape & { /** * Fetches one provider page. * @param token Continuation token from the previous page. @@ -216,12 +293,25 @@ export type TokenCursorOptions = CursorShape & { * * @example * ```ts + * // The cursor is walked after this call returns, so it takes its own lease rather than + * // borrowing the session's stub, and releases it when the walk is dropped. + * const walk = this.#gate.lease(); * return new TokenCursor({ * pageSize: 50, + * dispose: () => walk[Symbol.dispose](), * fetchPage: async (token, perPage) => { * const page = await api.listProjects({ cursor: token, limit: perPage }); * return { items: page.projects, nextToken: page.nextCursor }; * }, + * // Branch on emptiness, not on `terminal`: a spent mid-walk window is also empty, and a + * // `collections` scope naming no collection is refused. + * authorizePage: (items, { terminal }) => items.length === 0 + * ? walk.authorize( + * { title: "Projects", description: terminal ? "Listed the projects; there were none" : "Scanned a window of projects; none were visible" }, + * { kind: "baseline" }) + * : walk.authorize( + * { title: "Projects", description: `Read ${items.length} projects` }, + * { kind: "collections", ids: items.map(project => project.id) }), * }); * ``` */ diff --git a/packages/gatekeeper-kit/src/endpoint.ts b/packages/gatekeeper-kit/src/endpoint.ts index e33a11abe1..825f6f87d1 100644 --- a/packages/gatekeeper-kit/src/endpoint.ts +++ b/packages/gatekeeper-kit/src/endpoint.ts @@ -5,11 +5,14 @@ import { stripTrailingSlashes } from "@gadgets/workshop-shared/gatekeeper"; /** * Normalizes an operator-supplied vendor endpoint. Validation errors are display-safe and never echo * the raw input. + * + * This validates one URL; it is not a fetch policy. Following redirects by default can leave the + * allowlisted host and carry `Authorization` with it, so fetch with `redirect: "manual"`, or + * re-validate each `Location` and drop origin-scoped headers whenever the origin changes. * @param raw Endpoint URL. * @param options Host, label, and scheme policy. * @returns The origin and normalized path, preserving an explicit port and dropping query and * fragment. - * * @example * ```ts * const endpoint = normalizeVendorEndpoint(String(form.get("endpoint") ?? ""), { diff --git a/packages/gatekeeper-kit/src/kv.ts b/packages/gatekeeper-kit/src/kv.ts index db9cf614cc..611d5177b4 100644 --- a/packages/gatekeeper-kit/src/kv.ts +++ b/packages/gatekeeper-kit/src/kv.ts @@ -42,8 +42,10 @@ export type KvMutable = KvReadWrite & { export type KvScannable = KvMutable & { /** * Scans entries by key prefix. - * @param options Prefix to scan. + * @param options Prefix and optional storage-level page bounds. * @returns Matching key-value pairs. */ - list(options: { prefix: string }): Iterable<[string, T]>; + list( + options: { prefix: string; startAfter?: string; limit?: number }, + ): Iterable<[string, T]>; }; diff --git a/packages/gatekeeper-kit/src/observer-keys.ts b/packages/gatekeeper-kit/src/observer-keys.ts new file mode 100644 index 0000000000..5b69419025 --- /dev/null +++ b/packages/gatekeeper-kit/src/observer-keys.ts @@ -0,0 +1,38 @@ +/** Storage keys observer tracking owns, shared so no other layout can overlap them. */ + +/** Admitted observers, by ID. */ +export const OBSERVER_PREFIX = "observer:"; + +/** In-flight admission attempts, durable so concurrent reads already exclude the candidate. */ +export const OBSERVER_ATTEMPT_PREFIX = "observer-attempt:"; + +/** Cancellation nonces fencing those attempts. */ +export const OBSERVER_NONCE_PREFIX = "observer-nonce:"; + +/** + * One marker per owner-only read still awaiting the overseer. Transient: the read's own outcome + * deletes it, and one stranded by a crash is compacted into the latch below. + */ +export const OBSERVER_WITHHOLD_FENCE_PREFIX = "observer-withhold-fence:"; + +/** + * Set once an owner-only read was recorded, or may have been. Terminal: the binding is + * unshareable from then on, and nothing clears it. + */ +export const OBSERVER_WITHHOLD_LATCH_KEY = "observer-withhold-latch"; + +// Every one of these is scanned by prefix, so a foreign key landing inside one is read as an +// observer, an admission attempt, or an unsettled withheld read. +const RESERVED = [ + OBSERVER_PREFIX, OBSERVER_ATTEMPT_PREFIX, OBSERVER_NONCE_PREFIX, OBSERVER_WITHHOLD_FENCE_PREFIX, + OBSERVER_WITHHOLD_LATCH_KEY, +]; + +/** + * @param prefix Port-chosen storage prefix, including its separator. + * @returns The observer prefix it would scan into, or be scanned by; `undefined` when clear. + */ +export function reservedObserverOverlap(prefix: string): string | undefined { + return RESERVED.find( + reserved => prefix.startsWith(reserved) || reserved.startsWith(prefix)); +} diff --git a/packages/gatekeeper-kit/src/observer-tracker.ts b/packages/gatekeeper-kit/src/observer-tracker.ts index 80202e0446..972300ecba 100644 --- a/packages/gatekeeper-kit/src/observer-tracker.ts +++ b/packages/gatekeeper-kit/src/observer-tracker.ts @@ -1,9 +1,18 @@ -/** Durable collaborator admission and per-set observer exclusion. */ +/** Durable collaborator admission and per-collection observer exclusion. */ import { createLogger } from "@gadgets/backend-utils/logger"; import { generateNonce } from "./connect-nonce"; import type { KvScannable } from "./kv"; +import { perStorage } from "./per-storage"; import { requirePositiveInt } from "./positive-int"; +import { + OBSERVER_ATTEMPT_PREFIX, + OBSERVER_NONCE_PREFIX, + OBSERVER_PREFIX, + OBSERVER_WITHHOLD_LATCH_KEY, + OBSERVER_WITHHOLD_FENCE_PREFIX, + reservedObserverOverlap, +} from "./observer-keys"; const logger = createLogger<{ vendorId: string; observerId: string }>({ component: "gatekeeper.observers", @@ -32,52 +41,96 @@ export const OBSERVER_WITHHELD = /** The Durable Object KV surface used by observer tracking. */ export type ObserverKv = KvScannable; -type SetState = "pending" | "observed"; +type CollectionState = "pending" | "observed"; -/** Prepared observation state. Exactly one of `commit` or `discard` may run, synchronously. */ +/** + * Prepared observation state. Exactly one of `commit`, `discard`, or `abandon` runs, synchronously: + * `discard` only after a marked refusal proves nothing was recorded, `abandon` when the outcome is + * unknown. + */ export type ObservationCheck = { excludeObservers?: string[]; /** Commits prepared observation state. */ commit(): void; - /** Discards prepared observation state. */ + /** Reclaims prepared state after a refusal that recorded nothing. */ discard?(): void; + /** Releases in-memory bookkeeping when the outcome is unknown; durable fences stay. */ + abandon?(): void; }; -/** Internal: the check for a read that reveals no tracked set. */ +/** Internal: the check for a read that reveals no tracked collection. */ export const NOTHING_TO_RESOLVE: ObservationCheck = { /** Commits the empty observation check. */ commit() {}, }; -const OBSERVER_PREFIX = "observer:"; - -// Admission attempts are durable so concurrent reads already exclude the candidate. -const OBSERVER_ATTEMPT_PREFIX = "observer-attempt:"; -const OBSERVER_NONCE_PREFIX = "observer-nonce:"; - type ObserverAttempt = { verifier: V; at: number }; /** Maximum age of a pending observer-admission attempt. */ export const OBSERVER_ATTEMPT_LIFETIME_MS = 10 * 60 * 1000; -const OBSERVER_WITHHELD_KEY = "observer-withheld"; - -// Durable markers fence withheld reads until the overseer accepts or rejects them. -// A marker stranded by a crash fails closed. -const OBSERVER_WITHHOLD_PREFIX = "observer-withhold:"; - -const RESERVED_PREFIXES = [ - OBSERVER_PREFIX, OBSERVER_ATTEMPT_PREFIX, OBSERVER_NONCE_PREFIX, OBSERVER_WITHHOLD_PREFIX, - OBSERVER_WITHHELD_KEY, -]; - -const DEFAULT_MAX_TRACKED_SETS = 1000; +const DEFAULT_MAX_TRACKED_COLLECTIONS = 1000; // Keep verifier fan-out below the Workers subrequest ceiling. const DEFAULT_MAX_OBSERVERS = 10; const DEFAULT_CONCURRENCY = 6; +// What the reads disclosing one collection marker still owe it. A marker may be reclaimed only once every +// claimant settled and all of them settled as proven refusals -- one unknown outcome fences it for +// good, since a lost reply may have followed a durable record. A DO runs in one isolate, so +// in-memory tracking is sound; `perStorage` shares it across trackers over the same storage. +type CollectionClaim = { held: number; created: boolean; refusedOnly: boolean }; + +const collectionClaims = perStorage(() => new Map()); + +// Withhold markers this activation still owns. A durable marker missing here belongs to an +// operation whose outcome can no longer be learned -- a lost reply, or a restart that emptied this +// set -- so it is promoted to the permanent latch rather than left to accumulate. +const activeWithholds = perStorage(() => new Set()); + +/** How a prepared observation settled, per the overseer's answer. */ +type Outcome = "committed" | "refused" | "unknown"; + +/** + * Claims the collection markers one read discloses. + * @param claims Per-storage claim records. + * @param keys Set storage keys the read discloses. + * @param created Keys whose markers this read wrote. + */ +function claimSets(claims: Map, keys: readonly string[], created: Set) { + for (const key of keys) { + const claim = claims.get(key) ?? { held: 0, created: false, refusedOnly: true }; + claim.held += 1; + claim.created ||= created.has(key); + claims.set(key, claim); + } +} + +/** + * Settles one read's claims. + * @param claims Per-storage claim records. + * @param keys Set storage keys the read claimed. + * @param outcome How the read settled. + * @returns The keys whose markers every claimant has now refused, and nothing else accounts for. + */ +function settleSets( + claims: Map, + keys: readonly string[], + outcome: Outcome, +): string[] { + const reclaimable: string[] = []; + for (const key of keys) { + const claim = claims.get(key); + if (claim === undefined) continue; + if (outcome !== "refused") claim.refusedOnly = false; + if ((claim.held -= 1) > 0) continue; + claims.delete(key); + if (claim.created && claim.refusedOnly) reclaimable.push(key); + } + return reclaimable; +} + // Map with bounded concurrency while preserving result order. async function mapLimit( items: readonly In[], @@ -101,36 +154,48 @@ async function mapLimit( * identifiers. */ export type ObserverTrackerOptions = { - /** The binding's `ctx.storage.kv`. */ + /** + * The binding's `ctx.storage.kv`, passed as the same object every time. Pending-marker claims + * are coordinated in memory keyed on this object, so trackers handed distinct wrappers over one + * storage cannot see each other's in-flight reads: one refused read could then reclaim a marker + * another still depends on, and the next `addObserver` would admit against a collection it never + * checked. + */ kv: ObserverKv; - /** Key prefix for observed-set records; observers always live under `"observer:"`. */ - setPrefix?: string; + /** Key prefix for observed-collection records; observers always live under `"observer:"`. */ + collectionPrefix?: string; /** - * Canonicalizes a provider set ID so equivalent spellings share one stored ACL record. - * @param setId Provider set ID. - * @returns Canonical set ID for storage and ACL checks. + * Canonicalizes a provider collection ID so equivalent spellings share one stored ACL record. + * @param collectionId Provider collection ID. + * @returns Canonical collection ID for storage and ACL checks. */ - canonicalSetId?(setId: string): string; + canonicalCollectionId?(collectionId: string): string; /** - * Checks admission-level access before set ACLs. + * Checks admission-level access before collection ACLs, at admission only: losing Workshop membership + * is the revocation path. A provider needing per-read baseline freshness folds that check into + * `hasCollectionAccess`. * @param verifier Vendor-specific verifier capability. */ verifyBaseline?(verifier: V): Promise; /** - * Checks access to canonical provider sets. + * Checks access to canonical provider collections. * @param verifier Vendor-specific verifier capability. - * @param setIds Canonical set IDs. - * @returns Exactly one verdict per set ID; only literal `true` grants access. + * @param collectionIds Canonical collection IDs. + * @returns Exactly one verdict per collection ID; only literal `true` grants access. */ - hasSetAccess(verifier: V, setIds: readonly string[]): Promise; + hasCollectionAccess(verifier: V, collectionIds: readonly string[]): Promise; /** * Builds a generic denial message. - * @param setId Inaccessible canonical set ID. - * @returns A message that does not disclose the set ID. + * @param collectionId Inaccessible canonical collection ID. + * @returns A message that does not disclose the collection ID. */ - denyMessage?(setId: string): string; - /** Caps distinct sets before disclosure, so existing observers never become unverifiable. */ - maxTrackedSets?: number; + denyMessage?(collectionId: string): string; + /** + * Caps distinct collections before disclosure, so existing observers never become unverifiable. Size it + * from the provider's read fan-out: a refused read reclaims its slots, but a marker stranded by + * a crash is kept permanently, since a lost reply may still have recorded the observation. + */ + maxTrackedCollections?: number; /** Caps fan-out before reads can exceed Worker invocation limits. */ maxObservers?: number; /** Concurrent verifier round trips. */ @@ -139,27 +204,27 @@ export type ObserverTrackerOptions = { vendorId?: string; }; -// Brands set IDs after canonicalization so internal helpers cannot accept raw IDs. -type CanonicalSetId = string & { readonly __canonical: true }; +// Brands collection IDs after canonicalization so internal helpers cannot accept raw IDs. +type CanonicalCollectionId = string & { readonly __canonical: true }; /** - * Tracks observer admission and forward exclusion across revealed data sets. Persisting verifier + * Tracks observer admission and forward exclusion across revealed collections. Persisting verifier * capabilities requires `allow_irrevocable_stub_storage` and a durable service stub. * * @example * ```ts * #observers = new ObserverTracker({ * kv: this.ctx.storage.kv, - * setPrefix: "observedProject:", - * hasSetAccess: (verifier, projectIds) => verifier.hasProjects(projectIds), + * collectionPrefix: "observedProject:", + * hasCollectionAccess: (verifier, projectIds) => verifier.hasProjects(projectIds), * }); * ``` */ export class ObserverTracker { readonly #options: ObserverTrackerOptions; - readonly #setPrefix: string; - readonly #canonicalSetId: (setId: string) => CanonicalSetId; - readonly #maxTrackedSets: number; + readonly #collectionPrefix: string; + readonly #canonicalCollectionId: (collectionId: string) => CanonicalCollectionId; + readonly #maxTrackedCollections: number; readonly #maxObservers: number; readonly #concurrency: number; readonly #logger: typeof logger; @@ -171,27 +236,26 @@ export class ObserverTracker { constructor(options: ObserverTrackerOptions) { this.#options = options; this.#logger = options.vendorId ? logger.with({ vendorId: options.vendorId }) : logger; - this.#setPrefix = options.setPrefix ?? "observed:"; + this.#collectionPrefix = options.collectionPrefix ?? "observed:"; // The brand is asserted here and nowhere else on this path: whatever the caller's function // returns *is* the canonical spelling, by definition of the option. - this.#canonicalSetId = - (options.canonicalSetId ?? (setId => setId)) as (setId: string) => CanonicalSetId; + this.#canonicalCollectionId = + (options.canonicalCollectionId ?? (collectionId => collectionId)) as (collectionId: string) => CanonicalCollectionId; // A cap of zero refuses every read, and a window of zero never advances. - this.#maxTrackedSets = requirePositiveInt( - "maxTrackedSets", options.maxTrackedSets ?? DEFAULT_MAX_TRACKED_SETS); + this.#maxTrackedCollections = requirePositiveInt( + "maxTrackedCollections", options.maxTrackedCollections ?? DEFAULT_MAX_TRACKED_COLLECTIONS); this.#maxObservers = requirePositiveInt( "maxObservers", options.maxObservers ?? DEFAULT_MAX_OBSERVERS); this.#concurrency = requirePositiveInt( "concurrency", options.concurrency ?? DEFAULT_CONCURRENCY); - // Overlapping families scan into each other: set ids would come back as verifier keys, and - // stored verifiers would be handed to `hasSetAccess` as set ids. An empty prefix overlaps by - // scanning everything, and the same check rejects it. - for (const reserved of RESERVED_PREFIXES) { - if (this.#setPrefix.startsWith(reserved) || reserved.startsWith(this.#setPrefix)) { - throw new Error( - `Set prefix "${this.#setPrefix}" overlaps the reserved prefix "${reserved}".`); - } + // Overlapping families scan into each other: collection ids would come back as verifier keys, + // and stored verifiers would be handed to `hasCollectionAccess` as collection ids. An empty + // prefix overlaps by scanning everything, and the same check rejects it. + const overlap = reservedObserverOverlap(this.#collectionPrefix); + if (overlap !== undefined) { + throw new Error( + `Collection prefix "${this.#collectionPrefix}" overlaps the reserved prefix "${overlap}".`); } } @@ -202,10 +266,11 @@ export class ObserverTracker { * @returns A promise that resolves after admission is durable. */ async addObserver(id: string, verifier: V): Promise { - const { kv, verifyBaseline, hasSetAccess, denyMessage } = this.#options; - // A withheld read registers no set, so nothing here can establish this candidate was entitled + const { kv, verifyBaseline, hasCollectionAccess, denyMessage } = this.#options; + this.#compactWithholds(); + // A withheld read registers no collection, so nothing here can establish this candidate was entitled // to it. One still in flight counts: this candidate is absent from the exclusion list it sent. - if (kv.get(OBSERVER_WITHHELD_KEY) || this.#withholdInFlight()) { + if (kv.get(OBSERVER_WITHHOLD_LATCH_KEY) || this.#withholdInFlight()) { throw new Error(OBSERVER_WITHHELD); } this.#sweepStaleAttempts(); @@ -229,8 +294,8 @@ export class ObserverTracker { const checked = new Set(); for (;;) { - const setIds = this.#trackedSets().filter(setId => !checked.has(setId)); - if (setIds.length === 0) { + const collectionIds = this.#trackedCollections().filter(collectionId => !checked.has(collectionId)); + if (collectionIds.length === 0) { this.#requireCurrentAttempt(id, nonceKey, nonce); // Promotion and retirement in one awaitless run: the id is never both, and never neither. kv.put(`${OBSERVER_PREFIX}${id}`, verifier); @@ -240,16 +305,16 @@ export class ObserverTracker { } // Copied per call: the oracle may chunk destructively, and the length check below plus the // `checked` bookkeeping read this array afterwards. - const access = await hasSetAccess(verifier, setIds.slice()); + const access = await hasCollectionAccess(verifier, collectionIds.slice()); this.#requireCurrentAttempt(id, nonceKey, nonce); // A ragged answer denies rather than admits, in either direction. Short already denied // (`undefined !== true`); an answer *longer* than the question used to admit, which is the - // worse half -- index alignment is the only thing tying a verdict to a set, so a length the + // worse half -- index alignment is the only thing tying a verdict to a collection, so a length the // oracle disagrees about invalidates every verdict in the array rather than just the extras. - if (access.length !== setIds.length) throw new Error(OBSERVER_DENIED); - const denied = setIds.findIndex((_, index) => access[index] !== true); - if (denied >= 0) throw new Error(denyMessage?.(setIds[denied]!) ?? OBSERVER_DENIED); - for (const setId of setIds) checked.add(setId); + if (access.length !== collectionIds.length) throw new Error(OBSERVER_DENIED); + const denied = collectionIds.findIndex((_, index) => access[index] !== true); + if (denied >= 0) throw new Error(denyMessage?.(collectionIds[denied]!) ?? OBSERVER_DENIED); + for (const collectionId of collectionIds) checked.add(collectionId); } } catch (error) { // Only this attempt's records: whatever rotated the nonce owns them now. @@ -266,23 +331,43 @@ export class ObserverTracker { const { kv } = this.#options; // Enumerated before the marker goes down: a throw here must strand nothing. const excludeObservers = this.observerIds(); - const markerKey = `${OBSERVER_WITHHOLD_PREFIX}${generateNonce()}`; + const markerKey = `${OBSERVER_WITHHOLD_FENCE_PREFIX}${generateNonce()}`; kv.put(markerKey, true); + activeWithholds(kv).add(markerKey); + // Commit and an unknown outcome reach the same durable state: the overseer may hold the + // record, so sharing is fenced for good. Latch before delete, so no instant fences neither. + const fenceForGood = () => { + kv.put(OBSERVER_WITHHOLD_LATCH_KEY, true); + kv.delete(markerKey); + activeWithholds(kv).delete(markerKey); + }; return { excludeObservers, - // Latch before the marker goes: no state where neither fences. A failed latch write leaves - // the marker -- the overseer may already hold the record, so the fence must outlive the read. - commit: () => { - kv.put(OBSERVER_WITHHELD_KEY, true); + commit: fenceForGood, + abandon: fenceForGood, + // A marked refusal proves the overseer recorded nothing, so the fence can go. + discard: () => { kv.delete(markerKey); + activeWithholds(kv).delete(markerKey); }, - discard: () => kv.delete(markerKey), }; } + /** Latches markers stranded by an activation that died before settling one. */ + #compactWithholds(): void { + const { kv } = this.#options; + const active = activeWithholds(kv); + for (const [key] of kv.list({ prefix: OBSERVER_WITHHOLD_FENCE_PREFIX })) { + if (active.has(key)) continue; + // Latch before delete, as `commit` does: no instant where neither fences. + kv.put(OBSERVER_WITHHOLD_LATCH_KEY, true); + kv.delete(key); + } + } + /** @returns Whether any owner-only read remains unsettled. */ #withholdInFlight(): boolean { - for (const _ of this.#options.kv.list({ prefix: OBSERVER_WITHHOLD_PREFIX })) return true; + for (const _ of this.#options.kv.list({ prefix: OBSERVER_WITHHOLD_FENCE_PREFIX })) return true; return false; } @@ -331,39 +416,44 @@ export class ObserverTracker { } /** - * Prepares a set-scoped observation. - * @param setIds Provider set IDs disclosed by the read. + * Prepares a collection-scoped observation. + * @param collectionIds Provider collection IDs disclosed by the read. * @returns A check naming observers that lack access. */ - async prepareObservation(setIds: readonly string[]): Promise { - const { kv, hasSetAccess } = this.#options; + async prepareObservation(collectionIds: readonly string[]): Promise { + const { kv, hasCollectionAccess } = this.#options; // Canonicalized up front, so the keys written, the state compared, and the ids the oracle is // asked about are all the same spelling. - const canonical = [...new Set(setIds.map(setId => this.#canonicalSetId(setId)))]; - // Both partitions come from one state read per set, before the first await, so the "pending" + const canonical = [...new Set(collectionIds.map(collectionId => this.#canonicalCollectionId(collectionId)))]; + // Both partitions come from one state read per collection, before the first await, so the "pending" // writes below reflect storage as a concurrent addObserver will scan it. - const states = canonical.map(setId => [setId, this.#state(setId)] as const); + const states = canonical.map(collectionId => [collectionId, this.#state(collectionId)] as const); const promote = states .filter(([, state]) => state !== "observed") - .map(([setId]) => setId); - const untracked = states.filter(([, state]) => state === undefined).map(([setId]) => setId); + .map(([collectionId]) => collectionId); + const untracked = states.filter(([, state]) => state === undefined).map(([collectionId]) => collectionId); if (untracked.length > 0) { - const tracked = this.#trackedSets().length; - if (tracked + untracked.length > this.#maxTrackedSets) { + const tracked = this.#trackedCollections().length; + if (tracked + untracked.length > this.#maxTrackedCollections) { throw new Error( `This binding has read ${tracked} distinct items, the most it can track while remaining ` + "shareable. Bind a narrower scope."); } - for (const setId of untracked) kv.put(this.#setKey(setId), "pending"); + for (const collectionId of untracked) kv.put(this.#collectionKey(collectionId), "pending"); } + // Claimed after the capacity throw and before the first await, like the markers themselves, so + // no concurrent read can reclaim a marker this one still depends on. + const claims = collectionClaims(kv); + const claimed = canonical.map(collectionId => this.#collectionKey(collectionId)); + claimSets(claims, claimed, new Set(untracked.map(collectionId => this.#collectionKey(collectionId)))); const observers = [...this.#observers()]; const access = await mapLimit(observers, this.#concurrency, async ([id, verifier]) => { try { // Copied per verifier: the oracle may chunk destructively, and the exclusion check below // compares against this array. Shared, an emptied batch would make that check vacuous and - // admit every later observer to sets no oracle ever verified. - return await hasSetAccess(verifier, canonical.slice()); + // admit every later observer to collections no oracle ever verified. + return await hasCollectionAccess(verifier, canonical.slice()); } catch { // A throw excludes, like a denial: rejecting the batch would let one dead stub fail every // observation this binding makes. The caught value is deliberately not logged -- provider @@ -383,43 +473,52 @@ export class ObserverTracker { const verdicts = access[observer]; return verdicts === undefined || verdicts.length !== canonical.length - || canonical.some((_setId, index) => verdicts[index] !== true); + || canonical.some((_collectionId, index) => verdicts[index] !== true); }) .map(([id]) => id); return { excludeObservers: excluded.length > 0 ? excluded : undefined, commit: () => { - for (const setId of promote) kv.put(this.#setKey(setId), "observed"); + settleSets(claims, claimed, "committed"); + for (const collectionId of promote) kv.put(this.#collectionKey(collectionId), "observed"); + }, + abandon: () => void settleSets(claims, claimed, "unknown"), + discard: () => { + // Reclaimed by whichever claimant settles last, so a set two refused reads disclosed does + // not keep a slot -- and a marker anything promoted or left unaccounted for stays. + for (const key of settleSets(claims, claimed, "refused")) { + if (kv.get(key) === "pending") kv.delete(key); + } }, }; } /** - * Builds an observed-set storage key. - * @param setId Canonical set ID. - * @returns Storage key for the set. + * Builds an observed-collection storage key. + * @param collectionId Canonical collection ID. + * @returns Storage key for the collection. */ - #setKey(setId: CanonicalSetId): string { - return `${this.#setPrefix}${setId}`; + #collectionKey(collectionId: CanonicalCollectionId): string { + return `${this.#collectionPrefix}${collectionId}`; } /** - * Reads an observed set's state. - * @param setId Canonical set ID. + * Reads an observed collection's state. + * @param collectionId Canonical collection ID. * @returns Current state, including normalized legacy values. */ - #state(setId: CanonicalSetId): SetState | undefined { + #state(collectionId: CanonicalCollectionId): CollectionState | undefined { // `true` is the legacy encoding of "observed" some gatekeepers already have in storage. The kit // never writes it, and normalizing it here keeps the two spellings out of every other line. - const stored = this.#options.kv.get(this.#setKey(setId)); + const stored = this.#options.kv.get(this.#collectionKey(collectionId)); return stored === true ? "observed" : stored; } - /** @returns Every canonical set ID retained by this tracker. */ - #trackedSets(): CanonicalSetId[] { - return [...this.#options.kv.list({ prefix: this.#setPrefix })].map(([key]) => - key.slice(this.#setPrefix.length) as CanonicalSetId, + /** @returns Every canonical collection ID retained by this tracker. */ + #trackedCollections(): CanonicalCollectionId[] { + return [...this.#options.kv.list({ prefix: this.#collectionPrefix })].map(([key]) => + key.slice(this.#collectionPrefix.length) as CanonicalCollectionId, ); } diff --git a/packages/gatekeeper-kit/src/observers.ts b/packages/gatekeeper-kit/src/observers.ts index f45089a902..f9aebb69a6 100644 --- a/packages/gatekeeper-kit/src/observers.ts +++ b/packages/gatekeeper-kit/src/observers.ts @@ -2,8 +2,8 @@ import type { RpcStub } from "cloudflare:workers"; import type { - ApprovalQueue, GatekeeperUserVerifier, + ObservationAuthorizer, ObservationDescription, } from "@gadgets/workshop-shared/gatekeeper"; import { @@ -26,8 +26,38 @@ export { type ObserverTrackerOptions, } from "./observer-tracker"; -/** Defines collaborator admission and per-observation exclusion. */ -export interface ObserverStrategy { +/** + * The mark an overseer refusal of an observation carries, on `name` or a transport-stable `code`. + * A failure carrying it proves the observation was refused by policy *before* it was recorded, so + * prepared observer state may be reclaimed; any other failure leaves the outcome unknown, and + * durable fences must be retained. + */ +export const OBSERVATION_REFUSED_CODE = "ObservationRefusedError"; + +/** + * Matches an overseer observation refusal by `name` or `code`: capnweb rebuilds errors, keeping + * enumerable own props but not the name. Until the overseer marks its refusals nothing matches, so + * every failure takes the unknown-outcome path. + * @param error Caught error. + * @returns Whether the overseer refused the observation before recording it. + */ +export function isObservationRefused(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.name === OBSERVATION_REFUSED_CODE + || ("code" in error && error.code === OBSERVATION_REFUSED_CODE); +} + +/** + * How thoroughly a strategy checks observer access to the provider groupings a read discloses. + * + * - `"per-read"` — an ACL oracle runs for every observer on every scoped read. + * - `"no-observers"` — nobody is ever admitted, so there is no observer to check. + * - `"unsupported"` — observers exist and nothing checks them per grouping. A scoped read is + * refused, since ids that are silently discarded describe a check nothing performed. + */ +export type AclChecks = "per-read" | "no-observers" | "unsupported"; + +type ObserverStrategyBase = { /** * Attempts to admit an observer. * @param id Observer ID. @@ -39,12 +69,6 @@ export interface ObserverStrategy { * @param id Observer ID. */ removeObserver(id: string): Promise; - /** - * Prepares exclusions for observed sets. - * @param setIds Provider set IDs disclosed by the read. - * @returns Prepared observer state. - */ - prepare?(setIds: readonly string[]): Promise; /** @returns Retained observer IDs without fencing concurrent admission. */ observerIds?(): string[]; /** @@ -52,13 +76,36 @@ export interface ObserverStrategy { * @returns A check that fences admission until settled. */ prepareWithheld(): ObservationCheck; -} +}; + +/** + * Defines collaborator admission and per-observation exclusion. Baseline access is verified at + * admission only -- losing Workshop membership is the revocation path -- and only + * `trackedCollectionObservers` re-runs its ACL oracle for every observer on every scoped read. + * + * `aclChecks` is part of the contract, not a hint: only the `"per-read"` arm may carry `prepare`, + * so a strategy cannot claim a check it does not implement. + */ +export type ObserverStrategy = + | (ObserverStrategyBase & { + aclChecks: "per-read"; + /** + * Prepares exclusions for the groupings a read disclosed. + * @param collectionIds Provider grouping IDs disclosed by the read. + * @returns Prepared observer state. + */ + prepare(collectionIds: readonly string[]): Promise; + }) + | (ObserverStrategyBase & { + aclChecks: "no-observers" | "unsupported"; + prepare?: never; + }); // Baseline and public strategies cannot support owner-only reads. function cannotWithhold(): never { throw new Error( "This binding's strategy shares every read with admitted observers; use a baseline scope, " + - "or track observed sets to withhold a read."); + "or track observed collections to withhold a read."); } /** @@ -68,15 +115,18 @@ function cannotWithhold(): never { */ export function privateObservers(message: string): ObserverStrategy { return { + // Nobody is ever admitted, so a collection scope has no observer to exclude. + aclChecks: "no-observers", addObserver: async () => { throw new Error(message); }, removeObserver: async () => {}, - // Vacuously owner-only: no observer is ever admitted, so there is nobody to exclude. + // Owner-only by construction: no observer is ever admitted, so there is nobody to exclude. prepareWithheld: () => NOTHING_TO_RESOLVE, }; } /** - * Creates a resource-level ACL strategy. + * Creates a resource-level ACL strategy. `hasAccess` runs only at admission; an observer who loses + * access afterwards is caught at their next open, when the overseer re-admits them. * @param options ACL oracle and denial message. * @returns An ACL observer strategy. */ @@ -91,6 +141,8 @@ export function aclObservers(options: { denyMessage?: string; }): ObserverStrategy { return { + // Admission is resource-level, so child collection ids would be accepted and discarded. + aclChecks: "unsupported", addObserver: async (_id, user) => { // Only `true` admits, as in C: a malformed answer from a hand-written oracle denies rather // than admits, and the two strategies must not disagree on what counts as access. @@ -104,24 +156,33 @@ export function aclObservers(options: { } /** - * Creates a strategy that tracks observed set ACLs. + * Creates a strategy that tracks observed collection ACLs. * @param options Observer-tracker storage and ACL policy. - * @returns A tracked-set observer strategy. + * @returns A tracked-collection observer strategy. */ -export function trackedSetObservers(options: ObserverTrackerOptions): ObserverStrategy { +export function trackedCollectionObservers(options: ObserverTrackerOptions): ObserverStrategy { const tracker = new ObserverTracker(options); return { + aclChecks: "per-read", addObserver: (id, user) => tracker.addObserver(id, asVerifier(user)), removeObserver: async id => tracker.removeObserver(id), - prepare: setIds => tracker.prepareObservation(setIds), + prepare: collectionIds => tracker.prepareObservation(collectionIds), observerIds: () => tracker.observerIds(), prepareWithheld: () => tracker.prepareWithheld(), }; } -/** @returns A strategy that admits every observer. */ +/** + * Creates a strategy that admits every observer without consulting the provider. Appropriate only + * where the data carries no provider-side access distinction: a collaborator the provider itself + * would refuse still observes everything the binding reads. + * @returns An open observer strategy. + */ export function openObservers(): ObserverStrategy { return { + // Every observer sees every read, so collection ids would describe a distinction that is + // not being made. Declare such a read `baseline`. + aclChecks: "unsupported", addObserver: async () => {}, removeObserver: async () => {}, prepareWithheld: cannotWithhold, @@ -137,18 +198,25 @@ export function escapeObservationValue(value: string): string { return value.replace(/[\r\n]+/g, " ").replace(/[\\`*_{}[\]()#+.!|>~-]/g, "\\$&"); } -/** Describes whether a read uses baseline access, set ACLs, or owner-only disclosure. */ +/** + * Describes what a read discloses: the admission baseline, the provider groupings whose ACLs + * govern it, or nothing shareable at all. + * + * A `collections` scope names provider-side access-controlled groupings — a space, a project, a + * repo — not the individual rows returned. The gate refuses one under a strategy whose + * `aclChecks` is `"unsupported"`, since ids nothing verifies would describe a check that never + * ran; pick `trackedCollectionObservers` for a resource whose children carry their own ACLs, and + * `baseline` where admission already covers the read. It also refuses a `collections` scope + * naming none, so a read that returned nothing describes itself as `baseline`. + */ export type ObservationScope = | { kind: "baseline" } - | { kind: "sets"; ids: readonly string[] } + | { kind: "collections"; ids: readonly string[] } | { kind: "withholdFromObservers" }; /** Observation text completed by the gate with derived exclusions. */ export type ObservationInput = Omit; -/** The queue surface a session stages actions through; observations go only through the gate. */ -export type ActionQueue = Pick, "submitAction" | "bindHook">; - /** * Authorizes observations after applying the selected observer strategy. * @@ -160,42 +228,66 @@ export type ActionQueue = Pick, "submitAction" | "bindHoo * const projects = await this.#api.listProjects(); * await this.#observations.authorize( * describeProjects(projects), - * { kind: "sets", ids: projects.map(project => project.id) }, + * { kind: "collections", ids: projects.map(project => project.id) }, * ); * return projects; * } * ``` */ export class ObservationGate implements Disposable { - readonly #queue: RpcStub; + readonly #authorizer: RpcStub; readonly #strategy: ObserverStrategy; /** * Creates an observation gate. - * @param queue Duplicated approval-queue stub owned by the gate. + * @param authorizer Duplicated authorizer stub owned by the gate. A session that also stages + * actions keeps its own approval-queue stub; this one is the read-only surface. * @param strategy Observer strategy for this binding. */ - constructor(queue: RpcStub, strategy: ObserverStrategy) { - this.#queue = queue; + constructor(authorizer: RpcStub, strategy: ObserverStrategy) { + this.#authorizer = authorizer; this.#strategy = strategy; } /** - * Shares the gate's stub for staging actions, so a session holds one dup for observations and - * actions alike. Narrowed to the action surface: a raw `authorizeObservation` would skip the - * strategy's exclusions, so observations go only through `authorize()`. - * @returns The action surface of the queue, borrowed: the gate keeps ownership, never dispose it. + * Reaches the workspace git cache through the gate, so a gatekeeper whose API returns commit ids + * can advertise them without holding a stub of its own. Observations still go only + * through `authorize()`. + * + * The returned stub is **caller-owned**: dispose it when the read is done, or take it with + * `using`. The gate keeps its own authorizer stub either way. The promise pipelines, so a call + * on it need not be awaited first. The return type is the authorizer's own, so the stub stays + * `Disposable` rather than being flattened to a bare `GitCache` that `using` would reject. + * @returns The gatekeeper-scoped git cache. */ - get actions(): ActionQueue { - return this.#queue; + getGitCache(): ReturnType["getGitCache"]> { + return this.#authorizer.getGitCache(); } /** - * Releases the duplicated approval-queue stub. Disposing during isolate shutdown trips a fatal + * Opens a second gate over its own duplicate of the authorizer, for a capability that outlives + * the session that made it — a cursor handed to the gadget and walked later, most often. + * + * Both gates share this binding's strategy, so exclusions and fences stay one decision; only the + * stub is duplicated. The lease is **caller-owned**: release it when the capability it + * serves is released, typically from a cursor's `dispose`. Disposing the session gate does not + * disturb a lease, and disposing a lease does not disturb the session. + * + * Needs a real `RpcStub`: this calls `dup()`, which the overseer's stub has and a plain object + * does not. A gate built from a service binding cannot lease either, `dup` being reserved + * over RPC. + * @returns A gate the caller disposes independently. + */ + lease(): ObservationGate { + return new ObservationGate(this.#authorizer.dup(), this.#strategy); + } + + /** + * Releases the duplicated authorizer stub. Disposing during isolate shutdown trips a fatal * workerd assertion; shipped gatekeepers leave the release to RPC connection teardown. */ [Symbol.dispose](): void { - this.#queue[Symbol.dispose](); + this.#authorizer[Symbol.dispose](); } /** @@ -208,10 +300,13 @@ export class ObservationGate implements Disposable { const check = await this.#prepare(scope); const exclude = check.excludeObservers; try { - await this.#queue.authorizeObservation( + await this.#authorizer.authorizeObservation( exclude?.length ? { ...input, excludeObservers: exclude } : input); } catch (error) { - check.discard?.(); + // A marked refusal proves nothing was recorded, so prepared state is reclaimed; any other + // failure leaves the outcome unknown, and durable fences stay. + if (isObservationRefused(error)) check.discard?.(); + else check.abandon?.(); throw error; } check.commit(); @@ -228,12 +323,19 @@ export class ObservationGate implements Disposable { return NOTHING_TO_RESOLVE; case "withholdFromObservers": return this.#strategy.prepareWithheld(); - case "sets": + case "collections": if (scope.ids.length === 0) { throw new Error( - 'An observation scope of kind "sets" needs at least one set id; use ' + + 'An observation scope of kind "collections" needs at least one collection id; use ' + '{ kind: "baseline" } for a read the admission baseline covers.'); } + // Fail closed, as `prepareWithheld` already does for the mirror-image mismatch. Accepting + // ids this strategy cannot check would report a per-collection decision nothing made. + if (this.#strategy.aclChecks === "unsupported") { + throw new Error( + "This binding's strategy cannot enforce collection ACLs, so it must not be handed collection ids. " + + 'Track observed collections to enforce them, or declare the read { kind: "baseline" }.'); + } return (await this.#strategy.prepare?.(scope.ids)) ?? NOTHING_TO_RESOLVE; } } diff --git a/packages/gatekeeper-kit/src/positive-int.ts b/packages/gatekeeper-kit/src/positive-int.ts index 1753575da7..5d778cbc1c 100644 --- a/packages/gatekeeper-kit/src/positive-int.ts +++ b/packages/gatekeeper-kit/src/positive-int.ts @@ -1,7 +1,9 @@ /** Shared validation for finite positive integer bounds. */ /** - * Requires a finite positive integer so invalid bounds cannot silently disable their cap. + * Requires a finite positive integer so invalid bounds cannot silently disable their cap. Values + * past `Number.MAX_SAFE_INTEGER` are refused too: comparisons and increments stop being exact + * there, so such a cap no longer bounds anything. * @param label Value name used in errors. * @param value Number to validate. * @returns The validated number. @@ -12,8 +14,8 @@ * ``` */ export function requirePositiveInt(label: string, value: number): number { - if (!Number.isInteger(value) || value < 1) { - throw new Error(`${label} must be a positive integer, got ${value}.`); + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive safe integer, got ${value}.`); } return value; } diff --git a/packages/gatekeeper-kit/src/preview-oauth.ts b/packages/gatekeeper-kit/src/preview-oauth.ts index 1c96d928d6..8b7e4a5d34 100644 --- a/packages/gatekeeper-kit/src/preview-oauth.ts +++ b/packages/gatekeeper-kit/src/preview-oauth.ts @@ -148,10 +148,26 @@ function isCurrentCallback(url: URL, callback: URL): boolean { return url.origin === callback.origin && url.pathname === callback.pathname; } -function relayCallback(target: URL, callbackUrl: URL, state: string): Response { - for (const key of ["code", "error"]) { - const value = callbackUrl.searchParams.get(key); - if (value) target.searchParams.set(key, value); +/** + * The provider callback parameters the stable→preview relay forwards. `iss` is RFC 9207 mix-up + * defense and must survive the relay intact — every occurrence, empty values included, since the + * preview's own check is what decides whether the issuer is acceptable and a collapsed duplicate + * would hide the ambiguity from it. The error triple is what a provider's own failure page needs + * to say anything useful. Values are copied verbatim onto a target `validateReturnUrl` has already + * accepted, so the trust model is unchanged — `state` is never taken from this list, since the kit + * always sets its own last. + */ +const RELAY_FORWARDED_PARAMS: readonly string[] = + ["code", "error", "error_description", "error_uri", "iss"]; + +function relayCallback( + target: URL, + callbackUrl: URL, + state: string, + forwarded: readonly string[], +): Response { + for (const key of forwarded) { + for (const value of callbackUrl.searchParams.getAll(key)) target.searchParams.append(key, value); } target.searchParams.set("state", state); return Response.redirect(target.toString(), 302); @@ -163,6 +179,7 @@ export class PreviewOAuth { readonly #callback: URL; readonly #enabled: boolean; readonly #signingSecret: string | undefined; + readonly #relayParams: readonly string[]; /** Exact callback URI to send to the provider and retain for the code exchange. */ readonly redirectUri: string; @@ -177,7 +194,22 @@ export class PreviewOAuth { callbackUri: string; /** Stable/preview runtime variables and signing secret. */ env: PreviewOAuthEnv; + /** Additional provider callback parameters to forward across the stable→preview relay. */ + relayParams?: readonly string[]; }) { + const extras = new Set(); + for (const key of options.relayParams ?? []) { + if (key === "state") { + throw new PreviewOAuthConfigurationError( + 'OAuth relay parameters may not include "state", which the kit always sets itself.'); + } + if (RELAY_FORWARDED_PARAMS.includes(key) || extras.has(key)) { + throw new PreviewOAuthConfigurationError( + `OAuth relay parameter "${key}" is already forwarded.`); + } + extras.add(key); + } + this.#relayParams = [...RELAY_FORWARDED_PARAMS, ...extras]; this.#callback = configurationUrl(options.callbackUri, "OAuth callback URI"); const configuredRedirect = options.env.OAUTH_REDIRECT_URI; this.redirectUri = configuredRedirect || options.callbackUri; @@ -258,7 +290,10 @@ export class PreviewOAuth { if (!this.#enabled) throw new Error("OAuth return URLs are not allowed."); const target = validateReturnUrl(state.returnUrl, this.#callback, this.#enabled); if (!isCurrentCallback(target, this.#callback)) { - return { kind: "relay", response: relayCallback(target, callbackUrl, encodedState) }; + return { + kind: "relay", + response: relayCallback(target, callbackUrl, encodedState, this.#relayParams), + }; } } diff --git a/packages/gatekeeper-kit/src/simulation.ts b/packages/gatekeeper-kit/src/simulation.ts index 259dbe1b3d..3b5269db8d 100644 --- a/packages/gatekeeper-kit/src/simulation.ts +++ b/packages/gatekeeper-kit/src/simulation.ts @@ -203,12 +203,19 @@ export class ProvisionalIds { } /** - * Checks whether an ID has a durable binding. - * @param id ID to check. - * @returns Whether a binding exists. + * Checks whether an ID can be sent to the provider — a classified provider ID, or a provisional + * one bound to a target the classifier accepts. Without a classifier only a binding counts, + * since nothing can tell an unbound provisional from a provider ID. + * @param id Provisional or provider ID. + * @returns Whether the ID is safe to pass through — that is, not an unbound provisional + * reference. Classification is syntactic: nothing here confirms the provider holds the object. */ isResolved(id: Id): boolean { - return this.#bound(id) !== undefined; + if (this.#isProvisional?.(id) === false) return true; + const bound = this.#bound(id); + // Classified on the way out too, exactly as `requireResolved` does: a pair an instance with no + // classifier wrote may aim at something the provider still does not have. + return bound !== undefined && this.#isProvisional?.(bound) !== true; } /** diff --git a/packages/gatekeeper-kit/src/single-flight.ts b/packages/gatekeeper-kit/src/single-flight.ts index a73512fedd..a95c55fc76 100644 --- a/packages/gatekeeper-kit/src/single-flight.ts +++ b/packages/gatekeeper-kit/src/single-flight.ts @@ -3,6 +3,7 @@ /** * Coalesces concurrent work by key. Flights are installed synchronously and released after success * or rejection, so a later caller can retry. `forget()` stops new joins but does not cancel work. + * Keys compare with `Map` semantics, so an object key coalesces exactly the callers holding it. * * @example * ```ts @@ -13,8 +14,8 @@ * } * ``` */ -export class SingleFlight { - readonly #inFlight = new Map>(); +export class SingleFlight { + readonly #inFlight = new Map>(); /** * Joins the existing flight for a key or starts one. @@ -22,18 +23,27 @@ export class SingleFlight { * @param start Work to start when no flight exists. * @returns The shared in-flight result. */ - run(key: string, start: () => Promise): Promise { + run(key: K, start: () => Promise): Promise { const joined = this.#inFlight.get(key) as Promise | undefined; const flight = joined ?? start(); if (joined === undefined) this.#inFlight.set(key, flight); return this.#release(key, flight); } + /** + * @param key Flight key. + * @returns Whether a joinable flight is installed for the key — false during `start`'s + * synchronous prologue and after `forget`, even while forgotten work still runs. + */ + pending(key: K): boolean { + return this.#inFlight.has(key); + } + /** * Stops offering a flight to later callers. * @param key Flight key to forget. */ - forget(key: string): void { + forget(key: K): void { this.#inFlight.delete(key); } @@ -43,7 +53,7 @@ export class SingleFlight { * @param flight Flight being returned. * @returns The flight result. */ - async #release(key: string, flight: Promise): Promise { + async #release(key: K, flight: Promise): Promise { try { return await flight; } finally { diff --git a/packages/gatekeeper-kit/vitest.worker.config.ts b/packages/gatekeeper-kit/vitest.worker.config.ts index 12fc6f5527..738fb879d8 100644 --- a/packages/gatekeeper-kit/vitest.worker.config.ts +++ b/packages/gatekeeper-kit/vitest.worker.config.ts @@ -8,7 +8,11 @@ export default defineConfig({ miniflare: { compatibilityDate: "2026-09-04", compatibilityFlags: ["allow_irrevocable_stub_storage"], - durableObjects: { TRACKER_HOST: { className: "TrackerHost", useSQLite: true } }, + durableObjects: { + TRACKER_HOST: { className: "TrackerHost", useSQLite: true }, + CONFORMANCE_ACCOUNT: { className: "ConformanceAccount", useSQLite: true }, + CONFORMANCE_RESOURCE: { className: "ConformanceResource", useSQLite: true }, + }, }, })], test: { diff --git a/plans/gatekeeper-kit.md b/plans/gatekeeper-kit.md index 133139fd48..b91f4e1326 100644 --- a/plans/gatekeeper-kit.md +++ b/plans/gatekeeper-kit.md @@ -5,11 +5,28 @@ gatekeeper be written as a TypeScript spec plus service-specific sessions, inste lines of hand-copied plumbing. **Status.** Layer 1 (§4, the leaf modules) has landed and has been through a review pass against -both corpora. The §4 sections are reconciled against the shipped signatures — where the two ever -disagree, the code and its tests win. Google consumes the preview OAuth leaf; Layer 2 (§5, the -assembly) and §7 steps 8–16 are still proposal, so no gatekeeper has been ported to the assembly and -none of §5's ergonomics have met a real consumer. Findings that review raised and declined are -recorded in the obligations table (§4.8), each with the trigger that would revive it. +both corpora, plus a second audit from a consumer's point of view. The §4 sections track the +shipped signatures — where the two ever disagree, the code and its tests win, and `USAGE.md` plus +the JSDoc are the current consumer-facing truth. §4 narrative is a record of intent rather than +generated API docs, so read it for *why* and the source for *what*. + +That second audit closed five adoption gaps and added a conformance consumer +(`packages/gatekeeper-kit/__tests__/workerd/conformance/`) — a synthetic gatekeeper assembled from +every leaf against a fake provider, and the first thing to compose them. It gates *composition*, +not each leaf's contract: it does not yet exercise rejection, observer removal, revert, or +simulated pending reads, and each leaf's own suite remains the gate for its behaviour. Landing +since the review pass: terminal action +outcomes distinguish "known not applied" from "unknown"; action fences are a declared per-set +policy rather than an optional argument; observation strategies declare whether they can enforce +collection ACLs and the gate refuses a scope they cannot; cursors authorize every page they hand +out including the terminal empty one, and can `lease()` a gate that outlives their session; +journals and caches require a named keyspace; and `connect()` can fence the OAuth completion +window. The observation "set" vocabulary is now "collection". + +Google consumes the preview OAuth leaf; Layer 2 (§5, the assembly) and §7 steps 8–16 are still +proposal, so no gatekeeper has been ported to the assembly and none of §5's ergonomics have met a +real consumer. Findings that review raised and declined are recorded in the obligations table +(§4.8), each with the trigger that would revive it. ## 1. Introduction & high-level intent @@ -29,8 +46,9 @@ layers**: - **Layer 1 — leaf modules.** Small, standalone primitives behind per-file subpath exports: connect nonces and the two-stage handshake, preview OAuth callback relaying, browser status pages, a credential-expiry latch, HTTP error classification, credential storage with refresh - coalescing, observer strategies, a durable action journal, pure simulation helpers, a TTL cache, - and RPC cursors. Each is usable on its own; none requires the assembly layer. + coalescing, observer strategies, a durable action journal, a transactional action-file store, + pure simulation helpers, a TTL cache, and RPC cursors. Each is usable on its own; none requires + the assembly layer. - **Layer 2 — the assembly.** A `gatekeeperKit()` factory producing a typed spec (`define`, `resource`), pluggable auth strategies (`oauth2`, `tokenAuth`, or a hand-written `AuthStrategy`), an HTTP handler, and four abstract base classes (`KitVendorBase`, @@ -59,8 +77,10 @@ ironclad's generation counter in the internal repo), which is how sequencing bug the entire `wrangler.jsonc` (including migrations) byte-identical, and keeping live account DO storage readable through explicit legacy-key options. - **Layer 2 second consumer: `mcp-shared`** drops its private copies of nonce and HTML modules in - favor of the kit's leaf modules. No other existing gatekeeper is modified; follow-up PRs port - them one at a time. + favor of the kit's leaf modules. No other existing gatekeeper is ported *to the assembly*; + follow-up PRs take them one at a time. Leaf adoption already runs ahead of that: `gatekeeper-google` + imports `./preview-oauth` (`google.ts`, `oauth.ts`) and `./action-files` (`gmail-state.ts`), and + `gatekeeper-confluence` imports `./action-files` (`confluence-actions.ts`). - **Cloudflare Access stays out.** Internal gatekeepers authenticate through Cloudflare Access; that flow lives in the internal repo and will later be expressed as an `AuthStrategy` implementation. The seam is designed for it (see §5, `./auth`), but no Access code ships here. @@ -118,12 +138,14 @@ ironclad's generation counter in the internal repo), which is how sequencing bug ## 4. Layer 1: leaf modules Each module is a subpath export (`@gadgets/gatekeeper-kit/`), mirroring -`packages/mcp-shared/package.json`. Six files are internal instead: `serial-queue` (§4.12) and the -two split out of `actions` and `observers`, each reached through its owning subpath; `positive-int` -— one `requirePositiveInt` shared by every module that takes a bound; `kv` — the three KV surface -slices the leaves name, since seven modules had begun to carry byte-identical structural copies; -and `single-flight` — the in-flight coalescer four leaves had hand-rolled, on the same reasoning as -`serial-queue`. +`packages/mcp-shared/package.json`. Seven files are internal instead: `serial-queue` (§4.12); +`action-journal` and `observer-tracker`, split out of `actions` and `observers` and reached through +their owning subpaths; `positive-int` — one `requirePositiveInt` shared by every module that takes a +bound; `kv` — the three KV surface slices the leaves name, since seven modules had begun to carry +byte-identical structural copies; `single-flight` — the in-flight coalescer four leaves had +hand-rolled, on the same reasoning as `serial-queue`; and `per-storage` — the +process-local-value-per-storage-object helper behind credential refresh and notification +single-flights and observer claim counts. One spec discipline applies to every section below: a behavioral sentence must name the surface that carries it in the adjacent method list. Behavior with no named carrier is a spec bug (three @@ -262,6 +284,8 @@ which the `GatekeeperConnectCallback` contract explicitly tolerates. Failures lo event `credentials.expiry.notify.failed` and the caller's `vendorId` via `@gadgets/backend-utils/logger` (component `"gatekeeper.connect"`). Existing stored `true` latch values remain honored. +`CredentialCoordinator.connect()` calls `clearCredentialExpiryLatch` itself before installing a new +connection; the standalone export remains for hand-written account implementations. The latch key is `"expiredNotified"` — unchanged from every current gatekeeper — but **module-private rather than exported**: every latch in both corpora is that literal, ports adopt the two functions @@ -298,8 +322,24 @@ consumer side respectively: ```ts export class CredentialsExpiredError extends Error { + readonly code = "CredentialsExpiredError"; // transport-stable mark; name mirrors it constructor(message: string, opts?: { cause?: unknown }); } +export class CredentialsChangedError extends Error { // credentials replaced mid-operation: the + readonly code = "CredentialsChangedError"; // failure was stale and the caller re-enters. + constructor(opts?: { cause?: unknown }); // Fixed display-safe message +} +export function isCredentialsExpired(e: unknown): boolean; // matched by name or code: the RPC +export function isCredentialsChanged(e: unknown): boolean; // transport strips classes, and capnweb + // rebuilds errors keeping enumerable own + // props but not the name — code survives + +export type RejectionVerdict = "expired" | "superseded" | "unavailable"; + // expired — grant gone: provider-confirmed death (the account owns announcing it; delivery + // never adjudicated) or a disconnect discovered during adjudication (never notifies) + // superseded — a live successor replaced the rejected identity: refresh, heal, or reconnect + // unavailable — the heal failed for non-credential reasons; nothing adjudicated, and the source + // rethrows the caller's original provider error export class CredentialCoordinator { // lives in the UserAccount DO constructor(kv, opts: { // keys are fixed: "credentials", plus ":identity" and @@ -312,17 +352,23 @@ export class CredentialCoordinator { // lives in the Use Creds | undefined; // reassembles the grant those keys hold. Retired by // clear(), so a clear() (or a restart after one) cannot // resurrect a grant since replaced or revoked + discardMint?(mint: Creds): void | Promise; // awaited when a reconnect or revoke wins + // the identity fence after a successful mint; errors are + // logged as credentials.mint.discard.failed, never rethrown + vendorId?: string; // log attribution for the heal-failure/overtaken logs }); stored(): Creds | undefined; // mints an identity for a record that predates them, so credentials // and a fence are always surfaced together - connect(creds: Creds): void; // a (re)connect's install: rotates the connection generation, THEN - // commits (identity rotation + record write). Refresh commits - // internally through fresh()/rotate(); there is no public commit + connect(creds: Creds, // a (re)connect's install: rotates the connection generation, + opts?: { ifGeneration?: string }): void; + // then commits (expiry-latch re-arm + identity rotation + record + // write). Refresh commits internally through fresh()/rotate(); + // there is no public commit clear(): void; // retires the migration, rotates the identity and the connection // generation (rather than deleting them), THEN drops the record - identity(): string; // random per write; opaque, equality only — a counter is reset by the - // deleteAll() in revoke()/alarm(), which would reissue a fence value - // from the revoked grant. "" = never surfaced, and never a fence + identity(): string; // random per write; opaque, equality only. "" is reserved for a + // never-connected read, always adjudicates "superseded", and must + // never front credentials from a hand-written getCredentials connectionGeneration(): string; // survives refresh, rotated by connect()/clear(); the cache // authority (§4.10) and the account half of the action fence (§4.8). // Minted on first read, never "" @@ -330,29 +376,79 @@ export class CredentialCoordinator { // lives in the Use rotate(refresh: (current: Creds) => Promise): Promise; // refreshes now, whatever // the recorded expiry says: the provider rejected an unexpired // credential, and it is the only authority that matters + snapshot(refresh, opts?: { notify?: () => Promise }): + Promise>; // the account's getCredentials half: fresh(), then a + // SYNCHRONOUS re-read of record + identity + generation, so the + // triple is atomic against a connect() landing at the await + // boundary — the reason the helper lives here. A confirmed expiry + // of the still-stored grant awaits notify (§4.4's latch) before + // rethrowing — a reconnect landing mid-notify replaces the death + // and the fresh triple is served, while a disconnect landing + // there reads as not connected with the death as its cause; a + // disconnect is a user action and never notifies + adjudicateRejection(identity, opts: { refresh?; notify }): Promise; + // the account's reportCredentialsRejected half. "" (never- + // connected) answers "superseded"; every other moved fence + // resolves by successor — "superseded" when one is stored, + // "expired" when a disconnect left none (never notifying for the + // disconnect itself). No refresh means a grant-death provider: + // notify, "expired". Otherwise heal via rotate() — fence-keyed, + // so concurrent heals share one mint: success → "superseded"; + // confirmed death → notify, "expired" (the fence re-checked + // after the notify await resolves a mid-notification reconnect + // or disconnect by successor again); any other mint failure → + // logged account-side, "unavailable" (credentials intact), + // unless the fence moved meanwhile. No durable + // dead-grant mint latch: a repeat report costs one doomed provider + // call answering invalid_grant again, and notification is deduped + // by notifyCredentialsExpiredOnce's own latch — a port that + // measures mint spam adds a cooldown inside its refresh callback } export class CredentialSource { // held by User entrypoint / facet / verifier constructor(opts: { - account: () => AccountCredentialStub; // { getCredentials(): Promise<{ creds, identity, generation }>; noteCredentialsExpired(identity) } - isAuthError(e: unknown): boolean; // grant death only, never a per-resource denial + account: () => AccountCredentialStub; // { getCredentials(): Promise>; + // reportCredentialsRejected(identity): Promise — + // an adjudication of identity, never of notification delivery + // (that is the latch's, §4.4). A malformed or lost answer is + // "unadjudicated": the source rethrows the caller's original + // provider error and never dead-marks the identity. A structural + // two-method type: the coordinator helpers are the reference + // implementation, and a hand-written stub owns their invariants — + // atomic triple under a non-"" identity (the source refuses a "" + // read), moved-past gate, heal fenced on the rejected identity, + // honest verdicts } + isAuthError(e: unknown): boolean; // credential rejection — the provider refusing + // the presented credentials — never a per-resource denial; the + // account's heal inside the adjudication, not the classifier, + // tells a stale derived bearer from a dead grant expiredMessage: string; vendorId?: string; // log attribution }); get(): Promise; // reads the account; concurrent reads coalesce onto one round trip - run(fn: (creds: Creds) => Promise): Promise; // hands the call its creds, captures their - // identity; an auth failure under an identity a refetch has since - // superseded with a live successor is stale — rethrown as retry, - // not reported as expiry. No live successor, no retry: a mismatch - // alone can mean the read was fenced out, and its failure reports - authority(): string | undefined; // the connection generation of the last fetch, synchronously — - // named for its facet-side cache-authority role, wired through - // KvTtlCache.partitionedBy (§4.10). undefined before the first - // fetch, and from a reported expiry until a fetch started after + read(): Promise; // coalesces on the same account read as get()/run(), returns a + // fresh { identity, generation } object and never credentials + run(fn: (creds: Creds, read: CredentialRead) => Promise, + opts?: { replayable?: boolean }): Promise; // hands the call its creds plus a fresh + // { identity, generation } read object — the action-fence capture, + // since lastSeenGeneration() moves mid-operation — resolves a + // confirmed rejection through the account's verdict: "expired" → + // CredentialsExpiredError(expiredMessage); "superseded" → retry + // once when `replayable`, else CredentialsChangedError; + // "unavailable" or an internal "unadjudicated" answer → the + // caller's original provider error. At most two + // executions; an auth failure under an identity a refetch has + // since superseded with a live successor is stale and re-enters + // without an ask (§4.13) + lastSeenGeneration(): string | undefined; // last fetch's connection generation, synchronously — + // a diagnostic only: `partitionedBy` reads `cacheAuthority()` + // instead (§4.10). undefined before the first + // fetch, and from a reported — or superseded-answered — rejection + // until a fetch started after // the report adopts an undead identity: partition unknown, so a // cache keyed on it bypasses rather than serves. Last-seen and // shared — never the action-fence capture, which rides the - // generation of its own fetch + // CredentialRead of its own run attempt } ``` @@ -373,13 +469,17 @@ construction (homeassistant, http, gtmdata), two resolve once per MCP operation, connected-account credential path. So every operation reads the account's current `{ creds, identity, generation }`, and the only sharing is coalescing the concurrent reads one operation makes onto a single in-flight round trip. -The `generation` riding along is `connectionGeneration()`: the source records the last-seen value -and surfaces it synchronously as its `authority()` — named for the role, in the cache's own -vocabulary — which is what lets `KvTtlCache.partitionedBy(kv, source)` partition a facet-side cache -by principal (§4.10) without an extra account round trip per cache read. A -fixed TTL would instead keep a live facet serving a stale principal across a reconnect for the -length of the window. An expiry-gated cache is the shape to add if measurement ever demands one, and -it needs an `expiresAt` projection the stored/public credential split does not carry today (§10). +The `generation` riding along is `connectionGeneration()`. The source records the last-seen value +and surfaces it synchronously as `lastSeenGeneration()`, which is a diagnostic and never a +partition: it names the previous connection until the next fetch, so a cache keyed on it serves the +previous principal for a whole TTL after an in-place reconnect. `KvTtlCache.partitionedBy(kv, +source)` therefore reads `cacheAuthority()` (§4.10), which performs a live account credential read +and yields the generation only while the source still vouches for the fetched identity. That costs +one same-colo round trip per cache access and still avoids the provider request the entry exists to +cache — the deliberate trade against serving another principal's data. A fixed TTL would instead +keep a live facet serving a stale principal across a reconnect for the length of the window. An +expiry-gated cache is the shape to add if measurement ever demands one, and it needs an +`expiresAt` projection the stored/public credential split does not carry today (§10). The migration marker is written by `clear()` and by an `upgrade()` that found nothing, and nowhere else. While a canonical record exists, `stored()` never consults the migration path, so the marker @@ -412,21 +512,27 @@ rather than the coordinator instance, so a port constructing a coordinator per c coalesces — two concurrent rotates would otherwise each spend the same single-use refresh token, and the loser's `invalid_grant` would read as grant death. It is identity-fenced on **both paths**: it snapshots the stored record before awaiting, and commits a result only if the store still -holds that exact record — on a mismatch it returns the newer stored credentials when present and -throws `CredentialsExpiredError` when the store was cleared. The failure path carries the same -fence, but **only for grant death**: a `CredentialsExpiredError` propagates when the identity is -still current and otherwise re-reads the store (newer credentials → return them; cleared → -propagate), so grant A's stale death can never expire grant B. Every other refresh error propagates -untouched (grant death vs. infrastructure, §3) — fencing those would swallow an outage that raced a -reconnect, and reclassify one that raced a `clear()` as expiry. Refresh is not -transactional against provider-side rotation: a crash between the provider rotating a token and -the commit persisting it can lose the new token. The README documents this; nothing in the API -may promise otherwise. - -`CredentialSource.run` resolves the credentials, hands them to the operation, and captures their -identity before awaiting it. When `isAuthError(e)` is true and that captured identity is still the -last one a fetch adopted, it calls `account().noteCredentialsExpired(identity)` and throws -`new Error(expiredMessage, { cause: e })`, adding that identity to a dead set (per-activation and +holds that exact record — on a mismatch, a successful mint is handed to +`CredentialCoordinatorOptions.discardMint`, awaited, and then the coordinator returns the newer +stored credentials when present or throws `CredentialsExpiredError` when the store was cleared. +The handler is the provider-side drain for a mint that will never be stored; a throw is logged as +`credentials.mint.discard.failed` and never changes that result (`credentials.ts:139-145,332-378`). +The failure path carries the same fence, but **only for grant death**: a +`CredentialsExpiredError` propagates when the identity is still current and otherwise re-reads the +store (newer credentials → return them; cleared → propagate), so grant A's stale death can never +expire grant B. Every other refresh error propagates untouched (grant death vs. infrastructure, +§3) — fencing those would swallow an outage that raced a reconnect, and reclassify one that raced a +`clear()` as expiry. Refresh is not transactional against provider-side rotation: a crash between +the provider rotating a token and the commit persisting it can lose the new token. The README +documents this; nothing in the API may promise otherwise. + +`CredentialSource.run` resolves the credentials, hands them to the operation together with a fresh +`CredentialRead` — `{ identity, generation }`, constructed per attempt, never the source's internal +triple — and captures the read before awaiting. When `isAuthError(e)` is true, `run` resolves the +rejection through `account().reportCredentialsRejected(identity)` — the account's authoritative +verdict, with any healing done *inside* that ask (§4.13). `"expired"` throws +`CredentialsExpiredError(expiredMessage, { cause: e })`, adding that identity to a dead set +(per-activation and never evicted: growth is bounded by account commits, and stale failures mark identities out of commit order, so no eviction order is safe): the account keeps the grant until reconnect, so a refetch returns the same identity, @@ -434,19 +540,44 @@ and re-adopting its generation would let cache hits mask the outage — while a flight at the report is fenced out entirely, since a straggler can carry any old identity. A fetch started after the report, adopting an identity not in the dead set (successful refresh or reconnect), re-establishes the authority. Expiry also surfaces through the fetch itself — a failed -refresh rejects `getCredentials()` with an error named `CredentialsExpiredError` — and the source +refresh rejects `getCredentials()` with an error marked `CredentialsExpiredError` — and the source drops the authority there too, under the same fence so a straggler's stale rejection cannot clear a -revived partition. When a concurrent refetch has since adopted a **live successor** — a different +revived partition. `"superseded"` — a live successor already replaced the rejected identity, or +the account just healed past it — resolves as `CredentialsChangedError` with the authority left +unknown, or, +under `replayable`, as one internal retry: a fresh account read (the ask's fence bump forgot the +pre-ask flight, and the single-threaded account answers after the heal's commit), refused as +"changed" when its generation moved (a reconnect — never run under a principal the caller didn't +start with), resolved as expiry without a provider call when it re-serves a dead-set successor +the source last stood behind, refused as "changed" when the refetch was not itself adopted (a +fenced-out response is stale evidence that can postdate a reconnect the source already adopted, +with no adoption of its own to act on), refused as "changed" when its identity did not move (a +lazy account re-served the rejected credentials, whose refetch's own adoption is dropped again so +cache-first re-entries bypass rather than serve the partition it failed to defend), and otherwise +a second execution whose own rejection is adjudicated but never retried — at most two executions. +`"unavailable"` rethrows the caller's original provider error: nothing was adjudicated, and the +heal's own failure lives in the account's logs. When a concurrent refetch has since adopted a +**live successor** — a different identity not itself in the dead set — the failure is stale: reporting it would expire the grant that replaced the one the call used, and clearing the authority would drop the live grant's -partition, so `run` reports nothing, clears nothing, and throws a fixed retry message instead. A +partition, so `run` reports nothing and clears nothing. A replayable operation whose successor +shares the read's generation — a heal of the caller's own principal — retries once under it, no +ask spent; otherwise (non-replayable, or a moved generation marking a reconnect) `run` throws +`CredentialsChangedError` instead. A bare identity mismatch is not enough: a fetch fenced out by the report still hands its credentials to its caller without adopting them, and when those fail too, nothing live succeeded them — the -failure is fresh evidence and reports as expiry, or a later refetch would re-adopt the dead grant. -The account hop is itself wrapped, so its failure cannot replace `expiredMessage`; everything else -passes through. - -**`isAuthError` is the one classifier the agent can aim.** It decides that a *grant* is dead, and +failure is fresh evidence and goes to the account for adjudication, or a later refetch would +re-adopt the dead grant. If that account hop fails or returns a malformed verdict, the internal +answer is `"unadjudicated"`: `run` rethrows the provider error it was resolving and does not +dead-mark the identity (`credentials.ts:735-768`). Everything else passes through. Callers wanting +own retry policy skip `replayable` and match `CredentialsChangedError`/`CredentialsExpiredError` +(`isCredentialsChanged`/`isCredentialsExpired` — matching `name`, or the `code` that survives the +transports that strip it) in a plain loop; the source itself is optional, and a port that only wants coordinated storage uses +`get()` or the stub directly. + +**`isAuthError` is the one classifier the agent can aim.** It decides that the provider *rejected +the credentials* — and the account's heal inside the rejection adjudication then tells a stale +derived bearer from a dead grant — while the agent chooses which operations run — so a classifier matching bare 401/403 lets it retire a healthy connection by requesting one resource the grant does not cover, and the user is prompted to reconnect something that never broke. Per-resource denials are `isNoAccessError`'s job (§4.5); this @@ -467,19 +598,20 @@ The observer-verification primitives, plus the strategy objects the assembly con export function asVerifier(user: unknown): T; // the one sanctioned cast, with justification export const OBSERVER_DENIED: string; // default denial text export type ObservationCheck = { - excludeObservers?: string[]; commit(): void; discard?(): void; // exactly one of the two runs; -}; // both MUST be synchronous -- the gate does not await them + excludeObservers?: string[]; commit(): void; discard?(): void; abandon?(): void; +}; // exactly one runs synchronously: discard only after a marked refusal; abandon on an unknown + // outcome releases in-memory bookkeeping but keeps durable fences export type ObserverTrackerOptions = { kv; - setPrefix?: string; // observed-set records; default "observed:" - canonicalSetId?(setId: string): string; // identity when omitted; applied once, at entry + collectionPrefix?: string; // observed-set records; default "observed:" + canonicalCollectionId?(setId: string): string; // identity when omitted; applied once, at entry verifyBaseline?(verifier: V): Promise; // throwing coarse membership check, ADMISSION ONLY - hasSetAccess(verifier: V, setIds: readonly string[]): Promise; // batched; copied + hasCollectionAccess(verifier: V, setIds: readonly string[]): Promise; // batched; copied denyMessage?(setId: string): string; // default OBSERVER_DENIED; keep it generic — // shown verbatim to the denied collaborator vendorId?: string; // log attribution - maxTrackedSets?: number; // default 1000; refuses to reveal set 1001 + maxTrackedCollections?: number; // default 1000; refuses to reveal set 1001 maxObservers?: number; // default 10; refuses to admit observer 11 concurrency?: number; // default 6; concurrent verifier round trips }; @@ -502,7 +634,7 @@ and the overseer shows that text verbatim to the denied collaborator — so it s default, as every shipped multi-set gatekeeper's does: naming the set would disclose to a party without access that this workspace read it. The `setId` argument is for diagnostics. -**Port-time deployment requirement.** `trackedSetObservers` persists the verifier capability under +**Port-time deployment requirement.** `trackedCollectionObservers` persists the verifier capability under `observer:`, so a worker using it must set `compatibility_flags: ["allow_irrevocable_stub_storage"]` — every shipped gatekeeper already does, and without it the first `addObserver` fails with `DataCloneError: ServiceStub cannot be serialized in this context`. @@ -512,8 +644,10 @@ The Node fake cannot model any of this (§6), so a workerd suite carries it. `prepareObservation(sets)` marks newly-revealed sets `"pending"` before any `await` (so a concurrent `addObserver` sees them), -batch-checks every stored observer, and returns `excludeObservers` plus a `commit()` that -promotes the newly-revealed sets to `"observed"` only after the overseer authorizes the observation. +batch-checks every stored observer, and returns `excludeObservers` plus synchronous settlement: +`commit()` promotes the read's sets to `"observed"` after authorization; `discard()` reclaims only +the pending markers this check created after a marked refusal; `abandon()` releases only its +in-memory claims when the outcome is unknown, leaving durable markers in place. **The oracle is asked about every set in the read, not only the newly revealed ones.** A verdict recorded at first disclosure would otherwise be permanent, so an observer who lost provider-side @@ -525,21 +659,21 @@ still makes none, so the cost lands only on shared bindings, which is where the `verifyBaseline` stays **admission-only**. Running it per read would be N extra provider round trips per observation, no corpus gatekeeper does it, and the one gatekeeper that re-checks a baseline at all folds it into the batched set oracle (google, as `{ baselineAllowed, allowed[] }`) — which is -expressible today by returning all-`false` from `hasSetAccess`. +expressible today by returning all-`false` from `hasCollectionAccess`. The observer prefix is `"observer:"` and is **not** configurable: every tracker in both corpora (public linear, notion, confluence, slack, supabase, context, google; internal `gatekeeper-shared/src/observers.ts`) stores verifiers there, and only the set family varies — `observedProject:`, `observedCollection:`, `observedTeam:`, `observedItem:`, -`trackedConversation:`, `observed:` — which is what `setPrefix` exists for, so the ported supabase +`trackedConversation:`, `observed:` — which is what `collectionPrefix` exists for, so the ported supabase organization binding keeps reading its existing `observedProject:` rows. The constructor throws -when `setPrefix` overlaps `"observer:"` in either direction, which also rejects the empty prefix: +when `collectionPrefix` overlaps `"observer:"` in either direction, which also rejects the empty prefix: overlapping families scan into each other, returning set ids as verifier keys and handing stored -verifiers to `hasSetAccess` as set ids. A stored `true` always reads as "observed" with no opt-in +verifiers to `hasCollectionAccess` as set ids. A stored `true` always reads as "observed" with no opt-in flag — the kit never writes `true`, its only source is a legacy record, and in every corpus case that means observed. -`hasSetAccess` is batched because real oracles are: supabase answers N project refs with one +`hasCollectionAccess` is batched because real oracles are: supabase answers N project refs with one `/v1/projects` call. Because it is batched, **a verdict array whose length disagrees with the question denies or excludes, in either direction.** A short answer already denied by reading `undefined !== true`; an answer *longer* than the question used to admit, since the surplus entries @@ -588,13 +722,13 @@ export function aclObservers(opts: { hasAccess(v: V): Promise; // answers rather than throws; only `true` admits denyMessage?: string; }): ObserverStrategy; -export function trackedSetObservers(opts: ObserverTrackerOptions): ObserverStrategy; // C +export function trackedCollectionObservers(opts: ObserverTrackerOptions): ObserverStrategy; // C export function openObservers(): ObserverStrategy; // D export function escapeObservationValue(value: string): string; export type ObservationScope = | { kind: "baseline" } // admission already covers this disclosure - | { kind: "sets"; ids: readonly string[] } // per-set verification; an empty array is refused + | { kind: "collections"; ids: readonly string[] } // per-set verification; an empty array is refused | { kind: "withholdFromObservers" }; // withhold from every admitted observer export type ObservationInput = Omit; export class ObservationGate { @@ -614,29 +748,32 @@ Google Drive spells the *opposite* meaning the same way — `excludeObservers: t then a throw (`drive-session.ts:271-276`). One spelling, two opposite meanings, exactly one gatekeeper noticing. So `sets` refuses an empty array and names `baseline` as the way to say "the admission baseline covers this", and `withholdFromObservers` is Drive's shape as a first-class arm. +The scope describes disclosure while the strategy decides policy: a strategy declares whether it can +enforce collection ACLs and the gate *refuses* a scope naming collections it cannot check, and +choosing an ACL strategy for a resource whose children carry independent ACLs is the unsafe act (`observers.ts:167-175`). `authorize` resolves the scope to one `ObservationCheck` — `sets` → `strategy.prepare(ids)`, `withholdFromObservers` → `strategy.prepareWithheld()`, `baseline` → no strategy call at all — -then calls `queue.authorizeObservation`, adding `excludeObservers` only when the check produced any, -and invokes `commit()` after authorization succeeds or `discard?.()` when it refuses. `prepare` is -absent on A/B/D, which retain no per-set verdicts, so no exclusions there. `prepareWithheld` is -**required** with no fallback, because a silent no-exclusions default would let a misclassified -strategy void the caller's owner-only declaration — the failure would be a disclosure with no -signal. A answers vacuously (nobody is ever admitted); B and D throw, since their own premise is -that an admitted observer sees everything read here — a truthful owner-only read under them means -the resource belongs on C, and a read the premise covers should say `baseline`. Because the gate is -the only source of that field, there is no set-union merge left to do. Sessions call the gate for -every read instead of the raw queue. - -**A refusal releases reservations, not records.** `discard()` is for state that must not outlive the -read that made it — the withheld reservation below. Pending set records are not that: a read that -never committed disclosed nothing, so keeping them costs a slot of the tracking budget and denies an -observer a set nobody saw, and the next read of those sets re-verifies and promotes them. Every -tracked-set gatekeeper in the corpus does exactly this, in the same words — "failed attempts remain -pending and are rechecked" (`notion.ts:880`, `linear.ts:1026`, `supabase.ts:998`, -`google/observers.ts:10`), and none of them deletes a set record at all. An earlier draft reclaimed -them behind an in-memory claim count, which a lost activation defeats: the count is gone and the -record is not. +then calls `queue.authorizeObservation`, adding `excludeObservers` only when the check produced any. +It invokes `commit()` after authorization succeeds, `discard?.()` only when the failure carries +`OBSERVATION_REFUSED_CODE`, and `abandon?.()` for every unmarked error. `prepare` is absent on A/B/D, +which retain no per-set verdicts, so no exclusions there. `prepareWithheld` is **required** with no +fallback, because a silent no-exclusions default would let a misclassified strategy void the +caller's owner-only declaration — the failure would be a disclosure with no signal. A answers +vacuously (nobody is ever admitted); B and D throw, since their own premise is that an admitted +observer sees everything read here — a truthful owner-only read under them means the resource +belongs on C, and a read the premise covers should say `baseline`. Because the gate is the only +source of that field, there is no set-union merge left to do. Sessions call the gate for every read +instead of the raw queue. + +**A proven refusal reclaims only this read's reservations.** For withheld reads, `discard()` deletes +the read's `observer-withhold:` marker. For set-scoped reads it releases the check's per-storage +in-memory claims and deletes only the `"pending"` markers that check created, only while no +concurrent read still claims the key and storage has not promoted it. One isolate owns a Durable +Object, and `perStorage` shares the counts across tracker instances over the same storage object +(`observer-tracker.ts:88-113,380-453`). An unmarked failure calls `abandon()` instead: claims go, +durable fences stay. A crash-stranded marker is deliberately permanent because the vanished +in-memory count cannot prove the overseer failed to record the observation. Because the gate awaits the overseer after reading that list, C records a candidate under `observer-attempt:` before its first await and enumerates it as an observer: otherwise an @@ -735,8 +872,13 @@ export type JournalKeys = { }; type JournalState = // internal; the kit writes all five "staged" | "pending" | "claimed" | "failed" | "applied"; +export type ActionFence = { generation: string }; // opaque, equality-only staged connection export type JournalRecord = // returned by get(); error only on "failed" - { state: JournalState; action: A; error?: string }; + | { state: Exclude; action: A; fence?: ActionFence; error?: never; + undispatched?: never } + | { state: "failed"; action: A; fence?: ActionFence; error: string; + undispatched?: true }; // failed before the handler ran, so a + // rejection still owes its cleanup export type JournalEntry = // listed entries; structurally the { readonly id: number; readonly action: A }; // SimulationRecord createSimulationView takes export class ActionJournal { @@ -745,16 +887,19 @@ export class ActionJournal { maxPending?: number }); // `maxPending` defaults to 50; records carry a version marker, and an unmarked one goes to // upgradeRecord rather than being trusted - allocate(action: A): number; // sequential id, state "staged"; throws at maxPending + allocate(action: A, fence?: ActionFence): number; // sequential id, "staged"; throws at maxPending markSubmitted(id: number): void; // "staged" → "pending" markClaimed(id: number): void; // "staged" | "pending" → "claimed" restorePending(id: number): void; // "claimed" → "pending" - markFailed(id: number, error: string): void; // → "failed", terminal; reason capped; only reject clears it + markFailed(id: number, error: string, // → "failed", terminal; reason capped; only + options?: { undispatched?: boolean }): void; // reject clears it, and prunes last when owed rollbackSubmission(id: number): void; get(id: number): JournalRecord | undefined; // any state; checks both tiers remove(id: number): void; retain(id: number, action?: A): void; // post-apply write: retained record first, then the // delete; no-op on a "failed" record + retire(id: number): void; // retired-id tombstone first, then remove; idempotent + wasApplied(id: number): boolean; isRetained(id: number): boolean; // tier membership — trustworthy where open consumer states are not listPending(): JournalEntry[]; // "pending" + "claimed", ascending id; feeds createSimulationView listUndecided(): JournalEntry[]; // "pending" only — what a decision may still retire @@ -762,11 +907,13 @@ export class ActionJournal { export type ActionSubmitter = // the surface staging needs; `gate.actions` Pick, "submitAction">; // (§4.7) and a full stub both satisfy it export function stageAction(journal, queue: ActionSubmitter, - action: A, description: ActionDescription): Promise; + action: A, description: ActionDescription, fence?: ActionFence): Promise; export type ActionPresentation = // the approver-facing text; policy fields Pick; // come from the decl -export type ActionContext = { readonly id: number }; // durable, unique, stable across retries +export type ActionContext = { readonly id: number; readonly gitCache?: RpcStub; + readonly fence?: ActionFence }; // staged fence reaches apply/reject handlers +export type ActionApplyContext = { gitCache?: RpcStub; generation?: string }; export type ResolveOutcome = "applied" | "rejected" | "failed" | "reverted"; export class ActionApplyError extends Error {} // an apply handler's terminal failure; its @@ -791,13 +938,14 @@ export function defineActions>(defs: { retainApplied?: boolean; // explicit; default false — facet base asserts revert-hook consistency (§5.9) vendorId?: string; // log attribution; the assembly threads spec.id afterResolve?(host: H, outcome: ResolveOutcome): void | Promise; + isResolvedReference?(ref: string): boolean; // unresolved dependsOn refs never reach the provider }): ActionSet; export type BoundActionSet = { - submit(queue: ActionSubmitter, kind, payload): Promise; // serialized against other - // submissions only - apply(id: number): Promise; // both exclusive with each other and with - // runExclusive; void for an already-applied id + submit(queue: ActionSubmitter, kind, payload, + options?: { fence?: ActionFence }): Promise; // serialized against submissions only + apply(id: number, context?: ActionApplyContext): Promise; // context carries the canonical + // applyAction(action, cache) stub and current generation reject(id: number): Promise; autoApprovableKinds(): ActionKind[]; readonly retainsApplied: boolean; @@ -936,11 +1084,13 @@ overseer never heard of (linear's is permanent unless a later rejection sweeps i `linear.ts:1615-18`), whereas a staged orphan is invisible to every scan — silently leaked storage instead of a phantom approval. The trade is real, and the corpus has chosen both sides. -Two bounds keep this off the Layer 1 critical path: the window is a single overseer round-trip -against human reject latency, and the consequence is caught downstream. GitHub -(`github.ts:3287-3290`) and spotify (`spotify.ts:1709-1720`) fail cleanly when a provisional target -is unresolved, just as `ProvisionalIds.requireResolved` does here. The apply reports the clear -resolution error, and its record is restored to pending and remains retryable. +The residual staged window is bounded by one overseer round-trip against human reject latency, and +`ActionSetOptions.isResolvedReference` closes its provider-facing consequence. When configured +(naturally as `ref => provisionalIds.isResolved(ref)`), apply checks every `dependsOn` reference +after the connection fence and before claim or handler dispatch; an unresolved ref throws a plain +retryable error, leaves the record pending, and never hands the provisional string to the provider +(`actions.ts:155-175,465-489`). GitHub and spotify already fail cleanly at the same boundary +(`github.ts:3287-3290`, `spotify.ts:1709-1720`). The interleaving has two independent halves, and only one of them is closed. @@ -987,10 +1137,12 @@ per-vendor (only github has one today). With `retainApplied: false`, a resolution replayed after an apply whose RPC result was lost would find no record: the retry errors for an effect that succeeded, and a reject reports success, so the -overseer can label an executed action rejected. `retire()` therefore removes the record while -remembering the id in one bounded array (the prunable allowance) both verbs consult: the replayed -apply settles, the reject throws "no longer pending", and ids past the allowance degrade to the -unknown-id error rather than growing a tombstone tier. mcp-shared ships the same semantics as full +overseer can label an executed action rejected. `retire()` therefore writes the id into one bounded +retired-id array **before** removing the record. If removal is interrupted, `listPending()` and the +capacity scan already skip that tombstoned id, and a replayed apply idempotently finishes the +removal; reject throws "no longer pending" throughout. IDs past the allowance degrade to the +unknown-id error rather than growing an unbounded tombstone tier +(`action-journal.ts:234-318`, `actions.ts:430-440`). mcp-shared ships the same semantics as full rows capped at 100 (`action-store.ts:137,204`); the kit keeps only the ids. **The key layouts a port must reconcile (verified across both corpora, not inferred).** The kit's @@ -1036,26 +1188,33 @@ strategy. `JournalState` already carries the `state` field that makes the latter it does not settle is whether `reverted` is a journal state or facet-private, which is the §5.9 revert question and the reason this is not a slot the kit cuts in v1. -**Other port-time obligations, recorded here because no leaf can enforce them.** None is a Layer 1 -defect; each is either additive later or a fact about one provider that only bites on its own port. +**Port-time obligations and adjudications.** Some remain provider-specific work; others now record +the leaf contract that closed or deliberately adjudicated the finding. -| Obligation | Who it affects | Why it is deferred | +| Obligation | Who it affects | Disposition | | --- | --- | --- | -| **Ordering credential mutations against `revoke`.** A refresh in flight when `revoke()` wipes storage mints a token the identity fence correctly discards — leaving live provider-side authority nobody stored. Google serializes its four credential paths on one FIFO chain (`google.ts:405-427`), and even it leaks one error-path `kv.delete("refreshToken")` outside the chain (`:524-530`). | every port with a refresh flow | `revoke()` is not in the kit — the account base owns it (§5.6): it drains the refresh in flight and best-effort revokes its result as well as the captured grant. `coordinator.fresh()` already coalesces concurrent refreshes; the coordinator needs no queue of its own. | -| **Baseline re-checks on the exclusion path.** `verifyBaseline` runs at admission only, so an observer who later loses the binding-wide grant keeps observing. Google's batch result carries it per call — `{ baselineAllowed, allowed[] }` (`gatekeeper-google/src/observers.ts:48-49`) — and excludes on `!baselineAllowed` (`:206-215`). | google port first | Expressible today by folding the baseline into `hasSetAccess` (return all-`false`), so this is a documentation gap rather than a missing capability. Note google's baseline is a recorded *resource grant* (`resources.ts:203-205`), not org membership, and it *excludes* rather than removing the observer. | -| **`maxTrackedSets` is a default, not a corpus constant.** 1000 comes from google's generic default, but its concrete Drive tracker overrides to **2000** (`drive-observers.ts:49-53`), sized against `ceil(N/100)` subrequests. | supabase, notion, linear ports, which had no cap at all | A port inherits a bound it never had; the number is per-provider and belongs in that port's options. | +| **A revoke-raced mint now has a drain seam.** A refresh in flight when `revoke()` wipes storage may still complete after its identity fence moved, producing live provider-side authority the coordinator will never store. | every port with a refresh flow | **Resolved in Layer 1:** `CredentialCoordinatorOptions.discardMint` receives that successful fenced-out mint; `#refresh` awaits it before returning the winning credentials, and logs a throwing handler as `credentials.mint.discard.failed` without rethrowing (`credentials.ts:139-145,332-378`). `revoke()` itself still belongs to the account base (§5.6), which owns revoking the captured grant; the coordinator owns only the mint that lost its fence. | +| **Baseline verification is admission-time policy.** `verifyBaseline` and `aclObservers.hasAccess` run only when admitting an observer; the tracked-set oracle alone runs on every set-scoped read. | every observer port | **Adjudicated as doctrine:** Workshop membership removal is the revocation path, and a provider needing per-read baseline freshness folds that check into `hasCollectionAccess` (`observers.ts:50-54,103-129`; `observer-tracker.ts:149-161`). *Trigger for a new seam:* a provider whose binding-level grant is revocable independently of Workshop membership **and** whose reads are baseline-shaped. | +| **`maxTrackedCollections` is a default, not a corpus constant.** 1000 comes from google's generic default, but its concrete Drive tracker overrides to **2000** (`drive-observers.ts:49-53`), sized against `ceil(N/100)` subrequests. | supabase, notion, linear ports, which had no cap at all | A port inherits a bound it never had; the number is per-provider and belongs in that port's options. | | **`maxObservers` is a platform bound the corpus does not have.** Every retained observer costs one verifier call per read, and Workers cap a request at **32 Worker invocations** — past that the call throws, so a binding with too many collaborators fails *every* read rather than degrading. No shipped tracker caps this: notion, confluence, context, linear and internal `gatekeeper-shared` fan out over all observers with unbounded `Promise.all`, and google throttles concurrency without bounding the total. | every strategy-C port | The kit refuses at admission instead, which is the legible half of the same failure. The default is **10**, not 20: an observer count prices only the kit's own hop, and every verifier in the corpus spends a second invocation calling its account DO (`notion.ts:615-635`), so 20 observers is 40 invocations before the read does anything. The real ceiling is per-deployment, so the number belongs in that port's options. `concurrency` is a throttle and never a bound. | -| **Re-fetch after a reported expiry.** The account keeps the dead grant until reconnect — `noteCredentialsExpired` notifies, it does not clear — so any later `get()` fetches the same credentials back and its callers 401 again. | all | Self-healing and bounded: each round costs a redundant 401 (the account notifies once), never a wrong authorization. The source keeps reported identities in a per-activation dead set and refuses to re-adopt their generations, and fences out fetches already in flight at the report; without those, a cache hit under the restored partition never reaches the provider, so hit-only paths would mask the outage for the TTL and across the reconnect. A fetch started after the report, adopting an identity not in the set — successful refresh or reconnect — re-establishes the authority. | +| **Re-fetch after a reported expiry.** The account keeps the dead grant until reconnect — `reportCredentialsRejected` notifies, it does not clear — so any later `get()` fetches the same credentials back and its callers 401 again. | all | Self-healing and bounded: each round costs a redundant 401 (the account notifies once), never a wrong authorization. The source keeps reported identities in a per-activation dead set and refuses to re-adopt their generations, and fences out fetches already in flight at the report; without those, a cache hit under the restored partition never reaches the provider, so hit-only paths would mask the outage for the TTL and across the reconnect. A fetch started after the report, adopting an identity not in the set — successful refresh or reconnect — re-establishes the authority. | +| **Warm-path credential memo.** Every `run` and `get` opens an account round trip even when the same operation read credentials moments ago; the kit deliberately ships no consumer-side cache (§4.6), so a facet fanning out N provider calls pays N same-colo hops. | high-read-volume ports | The corpus survey behind §4.6 stands — 21 of 33 gatekeepers fetch per provider request, and the three that memoize gate on the *provider-issued expiry*, a projection the stored/public credential split does not carry today. An expiry-gated memo is additive (an `expiresAt` on the public projection plus a source option) and wants a port with measured hop cost in view, not a speculative default that would hold a stale principal for its window. | +| **403 scope-regrant healing.** The rejection adjudication heals *credentials* — a stale bearer minted from a live grant. A 403 whose cause is a missing scope is a different failure: the grant is alive, no mint fixes it, and the recovery is a reconnect flow with incremental consent. Classifying it as an auth error would retire a healthy connection; classifying it as no-access hides the regrant path from the user. | google port first (incremental-consent scopes) | Needs surface the kit does not have: a per-operation scope requirement, a reconnect prompt distinct from expiry, and provider-specific insufficient-scope detection (google's `403 insufficientPermissions` vs. its resource-level 403s). Land it with the first port whose provider does incremental consent, so the classification is designed against real error bodies. | | **Corrupt-record blast radius.** A throwing `upgradeRecord` propagates out of `#coerce`, so one unreadable legacy record makes `listPending()` throw and blinds the whole simulation overlay rather than dropping that entry. | ports supplying `upgradeRecord` | Both behaviours lose something — a throw blinds everything, skipping hides one pending action from its user — so pick it with a real corpus of legacy records in view. | -| **The retained tier is unbounded.** `#requireCapacity` scans only the pending prefix and skips `isRetained`, so `maxPending` bounds pending records and twice that many `staged`/`failed` ones, but never retained ones. A long-lived `retainApplied: true` binding accumulates one record per applied action indefinitely. | every retaining port | Retention is consumer policy and vendor caps differ; the binding must retire records through `runExclusive` under its own policy. | -| **Past its bound, a pruned `failed` record takes the only account of what went wrong.** The Workshop keeps a thrown `applyPendingAction` pending and visible (`overseer.ts:9497-9500`, "the action stays pending and the turn stays suspended"), so the journal record is the sole holder of the reason. Once more than `2 × maxPending` prunable records accumulate, the oldest are dropped: a later approve degrades to `Unknown pending action` and a later reject succeeds silently, which can lose an `ActionApplyError` warning that a provider effect partly landed. | any port accumulating more than twice `maxPending` un-rejected failures on one resource | Storage must be bounded, so something must eventually go; the choice is only what and when. Counting failures against the cap instead — the obvious alternative — converts a lost diagnostic into a provider-triggered denial of service, blocking all staging until the user hand-clears them. Staged-first pruning and the doubled bound push this out; closing it entirely needs a tier that keeps reasons after their records, which is the same unbounded retention the row above defers. | -| **A pending action is not fenced against the connection that staged it.** In-place reconnect keeps the same account DO and `userObjectId` and merely replaces the grant — `reconnectAccount()` is `record.account.reconnect()` (`user.ts:1541-1545`) and `markCredentialsRestored` re-describes on the assumption the user "may have re-authed with different info" (`user.ts:1655-1664`). Neither the overseer's approval record nor the facet's journal is touched, so an action staged under principal A can be approved and applied with principal B's credentials — and an object id that named one thing in A's tenant may name another in B's. No gatekeeper in either corpus fences this. | every port whose provider allows re-auth as a different principal | Not fixable with a nonce. A facet-side generation check followed by the handler's own `get()` is not atomic — a reconnect landing between them still yields B — so the fence has to be a credential read that takes the staged generation, `getCredentialsForGeneration(expected)` in the account DO, over the `connectionGeneration()` the coordinator already stores (§4.6; `identity()` cannot serve — every refresh supersedes it, invalidating every pending action). The stage-time half is delivered: the generation rides `getCredentials()`, so a staging call records the generation of its own credential read — never `CredentialSource.authority()`, a shared last-seen value (§4.10) that a concurrent fetch can move between the read and the record, stamping an action derived under A with B's generation. The apply-time fenced read remains open. That reaches only handlers that resolve credentials through the kit; a handler holding its own client still calls the provider unfenced, which is the cost of the escape hatch. Land it with the first port whose provider permits principal-switching re-auth, so the handler ergonomics are designed against a real one — supabase qualifies (reconnect may authorize a different org), so decide at that port whether to take the fence or explicitly re-scope this trigger. | -| **The expiry latch re-arms with two writes.** `clearCredentialExpiryLatch` clears the boolean and writes a fresh arm. Were the second to fail alone, an in-flight notification for the replaced credentials would match the surviving arm and latch the new ones — the one *silencing* failure in a module whose every other window fails toward a harmless duplicate notification. | every port with a refresh flow | Both writes are adjacent, awaitless and constant-size, so one implicit transaction carries them and no trigger separates them; the function's doc comment states that adjacency as the invariant to preserve. Every candidate fix is worse than the window: swapping the order makes the silence deterministic, and one combined record breaks the plain-boolean compatibility the latch key promises. If a port ever needs it, the escape is a single record holding arm and notified together. | -| **A crash mid-withheld-read closes admission for good.** The `observer-withhold:` marker goes down before the overseer is asked and is stranded by an activation that dies before settling; `addObserver` refuses while any marker stands, and nothing reclaims one. A read the overseer would have refused still leaves the binding unshareable. | every strategy-C port using `withholdFromObservers` | A stranded marker cannot tell a lost reply from a lost request, and the overseer's record is durable before the reply — so reclaiming on any schedule risks disclosing a recorded owner-only description to the next collaborator admitted, while over-fencing costs sharing on a binding already handling owner-only data. The attempt record's fail-closed trade, without the TTL escape. *Trigger:* a binding observed stuck closed with no `observer-withheld` latch. | -| **A lost `authorizeObservation` reply reopens admission over a standing withheld record.** `authorize` runs `discard()` on any throw, but a transport failure after the overseer's durable store is indistinguishable from a refusal — the marker comes down while the record stands, and the next collaborator admitted can read the owner-only description through `listActions()`. | every strategy-C port using `withholdFromObservers` | Keeping the marker on every throw inverts the earned-latch trade: refusal is the *ordinary* answer for a binding with observers (`overseer.ts:4590-4603`), and would permanently close admission for a read that disclosed nothing. Matching refusal message text was considered and rejected. The fix is a distinguishable refusal result in the `ApprovalQueue` contract, with `discard()` run only on it — batch with the next kernel contract change. *Trigger:* that change, or the first port shipping withheld reads. | +| **The retained tier is unbounded.** `#requireCapacity` scans only the pending prefix and skips `isRetained`, so `maxPending` bounds pending records and twice that many `staged`/`failed` ones, but never retained ones. A long-lived `retainApplied: true` binding accumulates one record per applied action indefinitely. | every retaining port | **Resolved as consumer-owned policy:** vendor caps still differ, so the journal does not invent one. `listRetained({ limit, cursor })` exposes resumable storage-bounded pages, including continuation past corrupt/non-applied rows that consume a storage page; the binding walks them inside `runExclusive` and calls `retire(id)` under its own policy. | +| **Past its bound, a pruned `failed` record takes the only account of what went wrong.** The Workshop keeps a thrown `applyPendingAction` pending and visible (`overseer.ts:9497-9500`, "the action stays pending and the turn stays suspended"), so the journal record is the sole holder of the reason. Once more than `2 × maxPending` prunable records accumulate, the oldest are dropped: a later approve degrades to `Unknown pending action` and a later reject succeeds silently, which can lose an `ActionApplyError` warning that a provider effect partly landed. | any port accumulating more than twice `maxPending` un-rejected failures on one resource | Storage must be bounded, so something must eventually go; the choice is only what and when. Counting failures against the cap instead — the obvious alternative — converts a lost diagnostic into a provider-triggered denial of service, blocking all staging until the user hand-clears them. Staged-first pruning and the doubled bound push this out; closing it entirely needs a tier that keeps reasons after their records, which is the same unbounded retention the row above defers. **Carve-out:** an `undispatched` failure is exempt and holds a slot instead, because what it loses is not a diagnostic but a rejection's obligation to release staging artifacts, whose leak is unbounded and ends in a worse block (`maxTotalBytes` refusing every file-backed action, with no user-visible remedy). One cascade can mark a whole dependent graph `undispatched`, so that bound is reachable in bursts. | **Narrowed:** a record whose provider outcome is unknown (`ActionOutcomeUnknownError`, or an orphaned `claimBeforeApply`) is never prunable and holds a capacity slot until the user clears it, so the one failure that says the provider may already have changed cannot be evicted. Ordinary terminal failures still age out. +| **Staged actions carry a declared authority fence.** `ActionFence` is stored on both journal-record arms and preserved through every transition; `defineActions` requires a per-set `fence` policy with per-kind `fenceOverrides`, `submit` refuses a fenced kind staged without one (and an unfenced kind staged with one), and `apply(id, { generation })` checks it before prerequisites, claim, or handler dispatch (`action-journal.ts:23-46,151-152,186-200,395-455`; `actions.ts:181-226,430-501`). | every port whose action payload is connection-scoped | **Resolved, and no longer opt-in.** The policy is declared rather than defaulted, because an omitted fence was invisible: the gatekeeper works, its tests pass, and an action approved under one provider account later applies under the next. The value stays opaque -- `"authority"` says the action is pinned, not what it is pinned to -- so a provider wanting an action to survive re-authorization of the same account fences on a stable account id instead. The kit still cannot capture the fence: it must ride the staging operation's own `CredentialRead`, and a second read taken inside `submit` could land after a reconnect. `CredentialCoordinator` rotates the connection generation on `connect()` and `clear()` only; token refresh preserves it. A reconnect or disconnect therefore trips the fence, including re-authorization of the **same** provider account because the generation is an opaque nonce. The kit treats the value as equality-only, so a port wanting account-scoped fencing may pass its own stable provider account id at submit and apply instead, and only the declared `generation` is stored. Omitting `generation` at apply is a retryable wiring error that leaves the record untouched; a mismatch records a terminal `undispatched` failure, strands dependents (`undispatched` too, since they never dispatched either), fires the failed-resolution hook, and tells the user to reject and resubmit — and that rejection runs each definition's `reject` hook, since no handler ran to own the staging artifacts. | +| **An interrupted retire is only as good as its tombstone.** `retire()` writes the retired-id tombstone before removing the record, so a split write degrades to a stale pending record that `#scan` and `#requireCapacity` filter on the tombstone and the next apply of that id retires. Two consequences follow from a split: past `2 × maxPending` retirements the tombstone is evicted and nothing filters the record, so it projects again and a later apply repeats an effect that landed; and the throw that split the write also skipped `afterResolve("applied")`, so the consumer's cache invalidation for a landed effect is lost and the healing retry does not re-fire it. | any port whose journal KV can tear a two-write sequence | **Accepted, not code — one stance for both.** The split needs `kv.delete` to throw between the two writes, which `ctx.storage.kv` cannot do: both land in one implicit transaction, the keys are fixed and short, and a broken output gate discards the whole turn rather than half of it. Only a consumer-supplied wrapper can fail one, which is what the fake KV in `__tests__` does — a shipped test modelling the tear is not evidence that workerd produces it. An eviction sweep was implemented and reverted (it put the deletes *before* the tombstone write, inverting the ordering the rest of the function depends on), and re-firing the outcome from the heal branch is declined on the same ground. *Trigger:* a journal KV that is not `ctx.storage.kv`, or an observed stale pending record with no tombstone. | +| **The expiry latch re-arms with two writes.** `clearCredentialExpiryLatch` clears the boolean and writes a fresh arm. Were the second to fail alone, an in-flight notification for the replaced credentials would match the surviving arm and latch the new ones — the one *silencing* failure in a module whose every other window fails toward a harmless duplicate notification. | every port with a refresh flow | Both writes are adjacent, awaitless and constant-size, so one implicit transaction carries them and no trigger separates them; the function's doc comment states that adjacency as the invariant to preserve. Every candidate fix is worse than the window: swapping the order makes the silence deterministic, and one combined record breaks the plain-boolean compatibility every shipped gatekeeper reads. **Narrowed:** every credential *replacement* re-arms through `#commit`, not only `connect()` — a successful refresh racing a notification for the credentials it replaces can no longer let that notification latch it, which was the same silencing class reachable with no storage failure at all. The legacy migration publishes without re-arming (`#publish`), since moving a grant between layouts replaces nothing and would otherwise re-announce a death the account already reported. | +| **A crash mid-withheld-read closes admission for good.** The `observer-withhold:` marker goes down before the overseer is asked, and an activation can die before settling it. | every strategy-C port using `withholdFromObservers` | **Resolved without weakening the fence:** a per-storage in-memory set owns markers for genuinely active reads. `addObserver` promotes every durable marker not owned by the current activation into the permanent `observer-withheld` latch, then deletes it; a marked refusal still removes its own marker. A crash or lost reply therefore remains fail-closed, but stranded markers no longer accumulate or remain a second indefinite state. No age heuristic: a legitimately slow authorization stays active however long it takes. | +| **A lost `authorizeObservation` reply no longer reopens admission over a standing fence.** `ObservationGate.authorize` calls `discard` only for a marked refusal; an unmarked transport or service error calls `abandon`, releasing in-memory bookkeeping while retaining durable set and withheld-read fences (`observers.ts:238-251`; `observer-tracker.ts:38-51,304-323,437-453`). | every strategy-C port | **Kit side shipped; kernel mark outstanding.** The kit reclaims prepared state only for a marked refusal and treats every other failure as an unknown outcome, which is the fail-closed half. The mark itself does not exist yet: `workshop-shared` defines no transport-stable refusal code, and both of the overseer's pre-recording refusal paths still throw plain `Error`s, so *every* real refusal takes the unknown-outcome path -- a legitimate policy refusal latches `observer-withheld` permanently and leaves tracked-collection markers holding capacity. Closing it is a kernel change (define the code and factory in `workshop-shared`, throw it from both paths, re-export from the kit) and is deliberately outside this branch. *Trigger:* the kernel PR that adds the mark. | +| **A first-time observer is not yet in the overseer's observer table while its gatekeeper admissions are in flight.** A gatekeeper can already name the fresh id in `excludeObservers`, but the former lookup treated the unknown id as stale and admitted the observation; after every admission completed, the observer record became durable without rechecking that read. | every collaborator opening a workspace for the first time | **Resolved in the kernel:** `OverseerImpl` registers each fresh id in an in-memory pending set before any admission await. Forward exclusion treats a matching pending id as an active blocker, then the successful promotion or terminal rollback removes it. A restart needs no durable marker because it aborts both the parked admission and the concurrent observation. | | **A dropped action kind strands its dependents silently.** `provides`/`dependsOn` are evaluated from the live definition, so an action staged under a kind a later deploy removed reports no refs, and the dependents it was holding open are not retired with it. | any port that removes a shipped action kind | The dependent stays pending and fails at the provider instead of naming the parent it needed, so what is lost is an error message, not an effect — a ref a gatekeeper declares in `dependsOn` is by definition an identifier the provider validates. Closing it means storing the refs on the record, which puts staging metadata inside the journaled action identity and threads it through every state transition. No corpus gatekeeper stores its graph either (§4.8), so the six that cascade port without this. *Trigger:* the first port to remove a shipped action kind. | | **A read during an in-flight apply can overlay an effect the provider already made real.** Simulated reads project `pending` and `claimed` records, and an apply is a provider round trip followed by the journal write, so a read landing between the two fetches the real effect and overlays the same action again — a transient duplicate in the *view*, never a second provider effect (resolution is serialized). | every port with continue-with-simulation actions | Inherent to overlaying local pending state onto remote reads: no atomic instant flips both, and it holds for every projected state, so dropping `claimed` from projection would only make the action vanish mid-apply instead. Serializing reads with resolution would stall the agent for the length of a provider call on every read — the trade submission already refuses — and `runExclusive` is the opt-in for a consumer that needs a consistent snapshot. Self-healing: the next read after the journal write is correct. *Trigger:* an agent observed acting on the duplicate, e.g. staging a corrective action against it. | -| **Pending observed-set records are never reclaimed.** `prepareObservation` marks untracked sets `"pending"` before awaiting the oracle and returns no `discard`, so a read the overseer refuses leaves them behind; only a later successful read of the same sets promotes them, and `#trackedSets()` counts pending rows against `maxTrackedSets`. Enough distinct refused reads and every `prepareObservation` throws "Bind a narrower scope". | every strategy-C port | Inherited behaviour, not introduced: google's shipped tracker writes pending before the await and returns `commit` only (`gatekeeper-google/src/observers.ts:186-236`), with `maxTrackedSets` alongside it. The naive fix is unsafe — two concurrent reads can mark one set pending, and a `discard` that deleted it after the other committed would un-track an observed set and let a later observer in unverified against it. So a reclaiming `discard` must delete only rows still `"pending"`, which is a concurrency argument that wants the fixture in front of it. *Trigger:* the first strategy-C port, or a binding observed to exhaust its budget. | +| **Refused reads reclaim their pending observed-set markers.** `prepareObservation` can remove the `"pending"` rows a read wrote once nothing is left to account for them. | every strategy-C port | **Resolved in Layer 1:** each disclosed key carries a per-storage in-memory claim recording how many reads still owe it, whether this generation of claims created its marker, and whether every claimant so far refused. The last claimant to settle reclaims the marker when all of them refused and storage still says `"pending"` (`observer-tracker.ts:88-136,424-473`). One isolate owns a Durable Object, so in-memory tracking is sound; `perStorage` shares it across trackers over the same storage object. Two markers stay by design: one whose claimants include an unknown outcome, since a lost reply may have followed a durable record, and one stranded by a crash, which a later read can never prove was refused because it did not create it. | +| **Marker reclamation is keyed on the storage object, not the storage.** `perStorage` holds the claim map in a `WeakMap` keyed by the `kv` passed to `ObserverTracker`, so trackers handed distinct wrappers over one Durable Object cannot see each other's in-flight reads. One refused read then reclaims a `"pending"` marker another still depends on, and the next `addObserver` admits an observer against a set the open read never checked it for. | any port that wraps `ctx.storage.kv` per call rather than passing it through | **Documented requirement, not enforced.** The object is the only discriminator available: keying on `collectionPrefix` or any stored value would cross-link separate Durable Objects sharing an isolate, which is worse. Durable claims would cost a write per disclosed set on the read path and reintroduce the crash-stranded state the in-memory design exists to avoid. The constraint is stated on the `kv` option itself and in the storage doctrine, and it is the same one refresh coalescing and `SingleFlight` already carry. *Trigger:* a port observed constructing its KV wrapper per call. | +| **A rejection's `restart` flag has no reader.** `GatekeeperResource.rejectAction` may return `{restart: true}` and `workshop-shared:860-869` promises "the Overseer will take care of the restart", but `overseer.ts:11229` awaits the call and discards its result, `packages/workshop-backend` reads `.restart` nowhere, and the kit's `reject` handler cannot represent it either. | any port whose simulation cannot be rolled back without restarting the gadget | **Recorded, no code.** Nothing observable changes today: no gatekeeper returns the flag, so honouring it and dropping it are indistinguishable. The platform decision — implement the restart in the overseer, or retire the field from the RPC contract — must be resolved before Layer 2 ships, since the kit would otherwise have to expose a flag with no effect. | +| **`ActionFileStore`'s RPC-boundary story is unproven.** The conformance consumer (`__tests__/workerd/conformance/`) exercises every other leaf in assembly, but not action files, so nothing establishes whether a gatekeeper streaming file bytes needs to hand anything across an RPC boundary — and if it does, whether that thing needs `RpcTarget` treatment the way cursors already have it. | any port using action files | **Open, cheap to settle.** The kit's stateful objects are Durable-Object-local by construction and only cursors extend `RpcTarget`; a `DataCloneError` is the loud failure if that assumption is wrong. Two boundary surprises have already come out of making the consumer *use* an API rather than assert about it — `getGitCache` was annotated so `using` could not compile, and `dup` turned out to be reserved over RPC so a gate built from a service binding cannot `lease()`. *Trigger:* the first port that stores action file bytes. | `stageAction` encodes the one ordering every gatekeeper must get right: allocate the record, `submitAction(id, description)`, then mark it submitted — and roll the record back and rethrow if @@ -1068,12 +1227,17 @@ reply stranded `staged`, so a retryable failure leaves it pending — projected from the rollback — instead of invisible to simulation while the overseer still lists it. `ActionSet.bind(journal, host)` returns a `BoundActionSet` with -`submit(queue, kind, payload)`, `apply(id)`, `reject(id)`, a readonly -`retainsApplied` (the resolved retention flag the facet base's assert reads, §5.9), -`autoApprovableKinds()` (filtered to `autoApprovable: true`, deduplicated by tag), and -`resolved(outcome)` — the facet base's way to fire `afterResolve(host, "reverted")` after its -revert hook, since the hook is closed over inside `defineActions`. There is no `revert(id)` -here — see §5.9. +`submit(queue, kind, payload, options?: { fence?: ActionFence })`, +`apply(id, context?: ActionApplyContext)`, `reject(id)`, a readonly `retainsApplied` (the resolved +retention flag the facet base's assert reads, §5.9), `autoApprovableKinds()` (filtered to +`autoApprovable: true`, deduplicated by tag), and `resolved(outcome)` — the facet base's way to fire +`afterResolve(host, "reverted")` after its revert hook, since the hook is closed over inside +`defineActions`. `ActionApplyContext` carries the `cache` stub received by canonical +`applyAction(action, cache)` as `gitCache`, plus the current connection `generation`. There is no +`revert(id)` here — see §5.9. +For a strict fence, the handler compares `ActionContext.fence` with the `CredentialRead` of its own +provider operation; that catches a reconnect landing after apply's entry check +(`actions.ts:84-102,465-501`). `reject` resolves to `void`. The canonical `rejectAction` may return `{ restart: true }` to ask the overseer to re-run the submitting turn, but the overseer awaits the call and discards its result @@ -1132,10 +1296,11 @@ and a stray rejection would destroy it. The guard behind both is `isRetained` string — plus the journal's retired-id memory, the only trace a non-retaining set keeps: it lets a retry of `apply` stay idempotent across activations, and stops a reject racing the apply (the overseer can deliver both concurrently) from reporting success for an action the provider ran. -On success the kit performs a **single atomic post-apply write**: the handler's returned -`{ action }` (apply-time artifacts such as created entity ids — the linear/notion pattern) merged -with the state transition — record retired, or moved to the retained tier as `"applied"` when -`retainApplied`. One writer by construction; handlers never write the journal mid-apply. An apply +On success the kit performs one **awaitless post-apply transition** after the provider effect: +the handler's returned `{ action }` (apply-time artifacts such as created entity ids — the +linear/notion pattern) is merged into a retained `"applied"` record, or a non-retaining resolution +writes its retired-id tombstone before removing the record. One writer by construction; handlers +never write the journal mid-apply. An apply that throws leaves the record so the user can retry (matching supabase) unless it threw `ActionApplyError`, which is terminal (below), and either way still fires `afterResolve(host, "failed")` — a partial provider effect is exactly when caches are most stale. @@ -1222,10 +1387,10 @@ The excess is clamped rather than trusted, which reads like a redundant check an negative `slice` end counts back from the array's own length, so under the bound `slice(0, -n)` silently drops the oldest records instead of nothing — worst at one below the bound. -`JournalRecord` is discriminated on `state`, so `error` exists only on a `"failed"` record and -always does there. The one fallback for a stored failure that lost its reason lives at `#coerce`, -the single storage boundary, rather than at each reader — `./actions` reads `record.error` with no -`??` behind it. +`JournalRecord` is discriminated on `state`: both arms may carry an opaque `fence`, while `error` +exists only on a `"failed"` record and always does there. The one fallback for a stored failure that +lost its reason lives at `#coerce`, the single storage boundary, rather than at each reader — +`./actions` reads `record.error` with no `??` behind it. ### 4.9 `./simulation` @@ -1261,7 +1426,8 @@ export class ProvisionalIds { options?: { kind?: string }): Id; // tagged: also keys `${ns}kind:${id}` bind(provisional: Id, real: Id): void; // keys `${ns}prov:${id}` resolve(id: Id): Id; // identity for unknown or provider ids - isResolved(id: Id): boolean; + isResolved(id: Id): boolean; // true for a classified provider id, so a + // dependsOn ref that is already real passes kindOf(id: Id): string | undefined; requireResolved(id: Id, options?: { expectedKind?: string }): Id; } @@ -1335,7 +1501,7 @@ both spellings, and the port writes `id ? [id] : []`. `__tests__/simulation.test `${namespace}prov:${id}` with no separator between the two consumer-supplied parts, so two instances in one DO whose namespaces are prefixes of each other can collide (`("", "prov:~1")` and `("prov:", "~1")` both land on `prov:prov:~1`). Left unchecked -deliberately — unlike `setPrefix` in §4.7, there is no fixed kit prefix for a consumer prefix to +deliberately — unlike `collectionPrefix` in §4.7, there is no fixed kit prefix for a consumer prefix to overlap with, only sibling namespaces the consumer chose, and no DO in either corpus holds more than one `ProvisionalIds`. Length-prefixing would change the documented key layout to defend against a consumer colliding with itself. @@ -1343,9 +1509,17 @@ consumer colliding with itself. ### 4.10 `./cache` `KvTtlCache` — `cached(key, ttlMs, load)` and `invalidateAll()`. Consumers construct it with -`KvTtlCache.partitionedBy(kv, source)`, which wires the authority to the source's live -`authority()`; the raw constructor `(kv, authority: () => string | undefined)` remains -for static and composite authorities. There +`KvTtlCache.partitionedBy(kv, source, options)`, which wires the authority to the source's +`cacheAuthority()` — a live read that yields the connection generation only while the source still +vouches for the fetched credentials, so a dead, pending or fenced-out identity bypasses the cache +rather than serving the previous principal. The raw constructor +`(kv, authority: () => string | undefined | Promise, options)` remains for +static and composite authorities. `options` is required and names the keyspace: `name` must match +`/^[A-Za-z0-9_-]+$/` and gives the logical cache family its own key and generation namespace under +a `cache:@:` prefix, and `legacyUnnamed: true` is the explicit opt-in to the shared pre-kit +layout a port already has in storage. The sigil is what makes the namespaces provably disjoint: +plain `cache::` would let a cache named `entry` write `cache:entry:generation`, which is the +unnamed layout's own entry for the key `"generation"`. There is no public `get`/`put` pair: a read-then-store cache whose two halves are separately callable puts the generation fence in the caller's hands, and the fence is the whole point. `cached()` reads the generation and the authority before `load()` and again after, and stores only if neither moved — so @@ -1378,35 +1552,38 @@ principal's data with the old identity, an entry that inverts the guarantee belo going stale. `CredentialCoordinator.identity()` cannot serve: every successful refresh supersedes it, so keying on it silently discards the whole cache each time the grant renews. The account-side source is `connectionGeneration()` (§4.6) — a live storage read that survives refresh and rotates -on `connect()`/`clear()`. A facet reaches it through -`CredentialSource.authority()`: the generation rides every `getCredentials()` and -the source surfaces the last-seen value synchronously, so the authority costs no round trip of its -own. `KvTtlCache.partitionedBy(kv, source)` is that wiring, blessed: it takes the source itself -(structurally — anything with `authority()`, so the cache module never names the credential -domain), so a port never writes the authority -closure a captured value, an `identity()`, or a static string would silently break. An authority -with more dimensions (resource scope, policy) composes its own closure for the raw constructor; -per-kind scoping stays in key segments (below). `undefined` — before the operation's first -credential fetch, or from a reported expiry until a fetch started after the report adopts a -different identity — -means the partition is unknown, and the cache **bypasses** rather than hits or stores: an entry -served or stamped without a partition is exactly the cross-principal leak the authority exists to -prevent. The residual window is a last-seen value going stale between an in-place reconnect and the -facet's next credential fetch — TTL-bounded, and closed at the next operation, since every -operation reads the account afresh (§4.6). An async authority was considered and rejected: a DO -round trip per cache read defeats the cache, and an RPC-fetched fence races the reconnect it -fences. - -**The generation record is deliberately not partitioned.** It is a single shared counter, so a bump -made under one authority also invalidates another's entries. That only ever over-invalidates, which -costs a refetch; under-invalidation is already impossible once entries carry the authority. One -mechanism, not two. - -The `"cache:"` prefix is fixed rather than a `namespace` option: cache families in the corpus are -key *segments* within one namespace (github `cache::`, notion `cache:page:`/`cache:db:`, -supabase `cache:entry:`), so a per-kind segment belongs in the caller's own `key`, and per-family -freshness is already per-read through `cached(key, ttlMs, …)` (notion's 30s/60s/1h split). No DO in -either corpus runs two separate durable TTL caches needing distinct namespaces. +on `connect()`/`clear()`. + +A facet reaches that fence through `CredentialSource.cacheAuthority()`. It performs a current +account credential read and returns the generation only when the source adopted, and still vouches +for, that exact identity/generation; a dead, pending, or fenced-out identity returns `undefined`. +`KvTtlCache.partitionedBy(kv, source)` takes that structural `cacheAuthority()` surface, so the cache +module never names the credential domain. A reconnect is therefore visible before the next hit, +including a hit-only workload in a long-lived facet. The cost is one same-colo account read per +cache access — which can run normal credential refresh — but the alternative serves the previous +principal's data for the full TTL. A disconnected account propagates its own error rather than +silently serving or bypassing. + +An authority with more dimensions (resource scope, policy) composes its own callback for the raw +constructor; per-kind scoping stays in key segments (below). `undefined` means the source cannot +vouch for a partition, so the cache bypasses rather than hits or stores. After a miss, the cache +reads the authority again before storing: a moved fence or unreadable account still returns the +successfully loaded value to its caller but does not cache it. This is the deliberate async +boundary; a last-seen synchronous accessor remains diagnostic only and is never a provider-data +partition. + +**Within one cache namespace, the generation record is deliberately not authority-partitioned.** +It is one counter, so a bump made under one authority also invalidates another's entries. That only +ever over-invalidates, which costs a refetch; under-invalidation is already impossible once entries +carry the authority. One mechanism, not two. + +The unnamed layout remains `cache:entry:` plus `cache:generation` for compatibility. That also +means every unnamed instance over one KV shares both keys and generation: colliding `cached()` keys +serve each other's values, and either instance's `invalidateAll()` invalidates both. A validated +`name` changes the layout to `cache:@:entry:` plus `cache:@:generation`, isolating +logical cache families without making per-kind segments a second option (`cache.ts:26-86,97-129`). +The sigil is load-bearing: plain `cache::` would let a cache named `entry` write +`cache:entry:generation`, which is the unnamed layout's own entry for the key `"generation"`. A stale, generation-mismatched or foreign-authority entry is an ordinary **miss**, left where it is: the generation counter lives under a stable key, so a bump never grows the keyspace, and the next @@ -1433,6 +1610,13 @@ from `gatekeeper-github/src/github.ts:809-929`. The scope is **pagination mechan owns provider paging state, buffers pages, and hands out fixed-size ones. `fetchPage` returns the session's own item type, so each provider cursor takes a single type parameter. +Provider-backed cursor options also share `dispose?(): void`. `BufferedCursor` exposes +`[Symbol.dispose]()` and calls that hook once, so a fetch callback may release a duplicated RPC stub +when the cursor target is dropped; without it, the callback may only borrow session-owned stubs. +`ArrayCursor` is unchanged because it owns no external resource, and `next()` after disposal keeps +its old behavior — whatever the callback released or borrowed decides the result +(`cursors.ts:34-87`). + The provider cursors stream; the split is what the paging state is, because a capped page moves each differently. A page number stays aligned under a cap — the provider clamps `perPage` consistently — so `PageNumberCursor` advances by one page. A numeric offset does not: jira clamps `maxResults` @@ -1555,53 +1739,130 @@ warning on `BoundActionSet.runExclusive`. export type AuthRetryOptions = { getToken(options: { forceRefresh: boolean; staleToken?: Token }): Promise; isAuthError(error: unknown): boolean; // the provider rejecting the credential, not 5xx + replayable: true; // explicit acknowledgment: `run` may execute twice }; export function withAuthRetry(options: AuthRetryOptions, run: (token: Token) => Promise): Promise; ``` -`CredentialSource.run()` has exactly two outcomes: pass the call through, or report the account -expired. That is right for the five gatekeepers whose 401 means the grant is gone (supabase, -github, linear, spotify, homeassistant), and wrong for the four that mint a short-lived derived -bearer from a longer-lived grant, where a 401 usually means *that bearer* is stale. All four +`CredentialSource.run()` resolves every credential rejection through the account's verdict, heal +included (§4.6); what `replayable` adds is the retry on a `"superseded"` answer. For the five +gatekeepers whose 401 means the grant is gone (supabase, +github, linear, spotify, homeassistant), the verdict alone is the whole story. The four that mint +a short-lived derived +bearer from a longer-lived grant, where a 401 usually means *that bearer* is stale, want the +rejection healed and the call retried. All four hand-roll the same single retry: marketo (`marketo-api.ts:462-477`), google (`auth-retry.ts:100-141`, which additionally force-refreshes with the rejected token's identity), notion (`notion-api.ts:1022-1052`) and confluence (`confluence-api.ts:527-550`). One retry, never a loop — a credential the provider rejects twice is not going to be accepted on a third attempt, and a loop turns a dead grant into a burst of token mints. `run` therefore executes -**at most twice** and must be replayable, which the doc comment states and which means building the -request inside it rather than passing a prepared one. `staleToken` carries the rejected token into +**at most twice**; the required `replayable: true` field acknowledges that the operation is safe to +execute twice, which means building the request inside it rather than passing a prepared one. +`staleToken` carries the rejected token into the refresh so a shared cache can skip a redundant mint when another caller already advanced it (google's shape). A non-auth error at either attempt propagates immediately: transport failures and 5xx are not credential problems, and retrying them here would double every provider outage. -**This module reports nothing, because it holds no credential identity to fence a report on.** An -expiry notification racing a reconnect is exactly what the identity fence exists to reject, and a -stale notifier that stepped on one would mark a healthy grant dead — so neither a failing `getToken` -nor a twice-rejected credential is reported from here. Both belong to the caller's -`CredentialSource.run(creds => withAuthRetry(...))`: `withAuthRetry` swallows the first 401 and -rethrows only a persistent one, so `run`'s catch fires exactly once, against the identity it -captured *before* the attempt (§5.6). - -**Where `getToken` comes from is the port's, and today it is a vendor RPC.** For the five providers -whose 401 means the grant is gone, there is nothing to wire: `CredentialSource.run` alone is the -whole story. For the four that mint a derived bearer, `getToken({ forceRefresh: true })` has to -reach the account, because §5.6 forbids refresh material crossing to a facet — so the mint is -account-side by construction, and the channel is per-vendor: google passes -`getAccessToken({ forceRefresh, staleToken })`, notion calls a separate `refreshCredentials()` -(doc'd at §5.6's projection rule). The kit does not name that channel yet; the §5.6 work item below -records the shape it should take, and until it lands a port supplies its own. - -`CredentialSource` cannot serve as that channel: `getCredentials()` is `coordinator.fresh(...)`, -which refreshes on expiry only, so a grant killed by `invalid_grant` while its access token is -still unexpired is re-served unchanged. `coordinator.rotate()` is the account-side half that -forces one; whatever RPC a port puts in front of it owes the same dead-grant treatment -`getCredentials()` gives — a still-current `CredentialsExpiredError` becomes -`noteCredentialsExpired()`, fenced on the identity. - -This closes the "401 retry" *logic* the §4.8 table recorded as deferred. The refresh channel the -retry depends on stays per-vendor until the work item lands. +**This module reports nothing, and a `CredentialSource.run()` wrapped around it cannot reliably +report either** *(revised 2026-09-03; this section previously blessed that composition)*: a +report is fenced on the identity the source observed, and `withAuthRetry`'s refresh happens where +no source sees it. For a grant that rotates on refresh — confluence persists a rotated refresh +token on every redemption (`confluence.ts:372`) — the mint supersedes the identity mid-operation, +so a persistent 401's report names the superseded grant and the account's fence gates it out: the +dead grant stays accepted and the Workshop is never told to reconnect. The retry a source user +needs therefore lives *behind the reporter* *(rewritten 2026-09-04 — the in-source replay this +section previously specified collapsed into the verdict protocol; the dated inversion below +records why)*: the account heals past a rejected-but-current credential *inside* +`reportCredentialsRejected`, and `run(operation, { replayable: true })` retries once on its +`"superseded"` answer. The whole protocol is three verdicts and one refetch. `"expired"` is +provider-confirmed grant death, already notified account-side — or a disconnect discovered during +the adjudication, which leaves no successor to retry into and never notifies: `run` throws +`CredentialsExpiredError(expiredMessage)` and marks the identity dead. `"superseded"` means a live +successor replaced the rejected identity — already replaced, or just healed past +by `adjudicateRejection`'s fence-keyed `rotate()` (§4.6): a non-replayable `run` throws +`CredentialsChangedError` and the caller re-enters; a replayable one refetches and retries. The +refetch is ordering, not hope: the ask's fence bump forgot the pre-ask flight, so the retry opens +a fresh account read, and the single-threaded account answers it after the heal's commit. Three +local guards keep the retry single-shot and honest — a moved generation rethrows as "changed" (a +reconnect: never run under a principal the caller didn't start with), an unmoved identity +rethrows likewise (a lazy hand-written stub re-served the rejected credentials; the source cannot +verify freshness for it, but it can refuse to burn the retry proving nothing), and an identity +already in the dead set resolves as expiry without a provider call. The retry's own rejection is +adjudicated but never retried — at most two executions, same doctrine as this module. +`"unavailable"` is the heal failing for non-credential reasons: nothing was adjudicated, the +caller gets the provider rejection it actually saw, and the token endpoint's error lives in the +account's logs. A read superseded before any ask — a live successor adopted mid-operation — still +skips the report entirely: its failure has nothing to tell a caller who only needs to re-enter. +Identity succession is the account's to adjudicate: the moved-past gate resolves any identity +that is not its current one by successor — `"superseded"` when a live one is stored, `"expired"` +after a disconnect ("" — a never-connected read — never matches, always `"superseded"`) — and +the verdict adjudicates identity, never notification delivery, whose latch deliberately stays +unset on a failed callback so a later expiry re-notifies. The rejected +authority drops at the ask: the rejection already proves the snapshot cannot vouch whichever way +the answer goes — dead, its partition could serve the next principal stale data on a hit; +superseded, it no longer vouches for the current principal (§4.10) — so cache-first readers +bypass during the round trip instead of serving the rejected partition. A read landing mid-ask is +served to its caller but never adopted — the pending ask blocks handing the rejected identity's +partition back before the verdict, so the bypass holds for the whole round trip — and the +authority drops again with the fences at the verdict, while the death mark itself waits for an +`"expired"` answer. Asks coalesce per identity: the verdict adjudicates the +identity, not the report, so concurrent reporters of one grant share the account round trip — +and the account's fence-keyed mint flight collapses their heals onto one provider call; each +reporter still takes its own drops around the shared answer. +`withAuthRetry` remains for token flows that hold no source, where nothing reports; a configurator +holds one (`AccountHandle.creds`, §5.1) wired through the same account stub, keeping refresh +material account-side (§5.6). + +**Inverted 2026-09-04, superseding the 2026-09-03 adjudication that kept the replay +source-side.** That adjudication weighed the account-side alternative — the account minting +inside the report and answering `"superseded"` — and rejected it on four costs. Re-weighed with +the branch built and pressure-tested against its consumers (of which there are zero), each fell. +*Caller-visible retry:* it isn't — `run` retries internally on the `"superseded"` answer, so the +routine stale-bearer 401 recovers exactly as invisibly as the in-source replay did, and the heal +now also covers **non-replayable** operations, closing the footgun where a stale derived bearer +on one falsely retired a healthy account (the old protocol could only report it as expiry). +*A third account round trip:* one to two extra same-colo RPCs on an error path only, priced +against a token mint and a provider 401 already being spent. *The healthy account takes authority +drops:* the ask-time drop is a cache-bypass window until the next read, `undefined` means bypass +— never a wrong answer — and zero cache consumers exist today. *One-retry ownership:* the +**source** owns replay-attempt counting and permits at most two executions of one `run`; the +**account** owns mint ordering and verdict authority. Its single-threaded DO plus the fence-keyed +mint flight collapses concurrent heals, while `notifyCredentialsExpiredOnce`'s durable latch +deduplicates notification. That division removes the source-side proposal's per-read replay flight +and `#crossed`/`#seen` generation bookkeeping without moving replay counting into cross-request +state: the account answers what happened to the rejected identity, and the source alone decides +whether its caller may spend the one retry. The deciding evidence is unchanged: every observed real +implementation (mcp-shared's `noteCredentialsExpired`, google's account-side mint with its +`staleToken` gate) already puts mint/verdict ordering account-side; the kit had armored the consumer +and left the account bring-your-own. Deliberately not carried over: a durable dead-grant mint +latch — a repeat report +against a dead grant costs one provider call answering `invalid_grant` again, same verdict, and a +port that measures mint spam adds a cooldown inside its `refresh` callback (google's +`#mintFailure` shape), which is the escape hatch's job, not the kit's. The residual costs, +accepted: heal-infrastructure errors reach the caller as the original 401 with the token +endpoint's error in account logs; a double fault — the 401 plus a lost RPC reply after a +successful heal — also surfaces that original provider error, never an invented expiry, and the +next fetch recovers; and a hand-rolled account carries the ordering +contract the coordinator helpers otherwise own, mitigated by `adjudicateRejection` being the +reference implementation and by the source's same-identity retry guard. + +**Where the refresh comes from is still the port's, and it is account-side by construction.** For +the five providers whose 401 means the grant is gone, there is nothing to wire: the account +passes no `refresh` to `adjudicateRejection`, so a current-identity rejection notifies and +answers `"expired"` before any retry (a grant-death port passing `replayable` is harmless). For +the four that mint a derived bearer, the mint is the `refresh` callback handed to +`adjudicateRejection` — bare by design, so provider-specific mint logic (cooldowns, `staleToken` +skips, scope handling) lives inside the port's callback, not in kit options. +`getCredentials()` alone cannot serve: it is `coordinator.fresh(...)`, which +refreshes on expiry only, so a grant killed by `invalid_grant` while its access token is still +unexpired would be re-served unchanged — `adjudicateRejection`'s `rotate()` is what forces the +mint past it. + +This closes the "401 retry" logic the §4.8 table recorded as deferred and names its refresh +channel (the `refresh` callback of `CredentialCoordinator.adjudicateRejection`), superseding the +§5.6 deferral below. ### 4.14 `./endpoint` @@ -1696,22 +1957,138 @@ export type PreviewOAuthEnv = { OAUTH_STATE_SIGNING_SECRET?: string; }; export type PreviewOAuthState = { userObjectId: string; oauthNonce: string }; +export type PreviewOAuthCallbackResult = + | { kind: "local"; state: PreviewOAuthState } + | { kind: "relay"; response: Response }; +export class PreviewOAuthConfigurationError extends Error { + constructor(message: string, options?: ErrorOptions); +} export class PreviewOAuth { - constructor(options: { callbackUri: string; env: PreviewOAuthEnv }); + readonly redirectUri: string; + constructor(options: { + callbackUri: string; + env: PreviewOAuthEnv; + relayParams?: readonly string[]; + }); + createAuthorizationState(state: PreviewOAuthState): Promise; + handleCallback(callbackUrl: URL): Promise; } ``` -This is Google's preview callback relay generalized without changing its wire format: direct flows -retain the `64hex:64hex` state, while preview flows use a ten-minute HS256 JWT carrying the same two -identifiers and the preview return URL. The factory returns the exact redirect URI to persist through -the code exchange, creates provider-facing state, and handles callbacks atomically — callers receive -either verified local state or an already-filtered relay `Response`. Return URLs are limited to the -stable callback's exact path and Worker Preview host suffixes; only `code`, `error`, and unchanged -state cross the relay. Google is the first consumer. Other gatekeepers can adopt the leaf without -porting to the assembly. +This is Google's preview callback relay generalized without changing its state wire format: direct +flows retain the `64hex:64hex` form, while preview flows use a ten-minute HS256 JWT carrying the +same two identifiers and the preview return URL. `redirectUri` is the exact value to persist through +the code exchange; `createAuthorizationState` builds provider-facing state, and `handleCallback` +returns either verified local state or an already-filtered relay `Response`. Return URLs are limited +to the stable callback's exact path and Worker Preview host suffixes. The relay forwards `code`, +`error`, `error_description`, `error_uri`, and `iss` plus constructor-configured `relayParams`; +`state` is kit-owned and always written last, and either adding it or duplicating a default throws +`PreviewOAuthConfigurationError`. Every occurrence of a forwarded parameter is appended, empty +values included: `iss` is the RFC 9207 mix-up defense and the preview's own check is what decides +whether an issuer is acceptable, so a collapsed duplicate would hide the ambiguity from the code +that enforces it. It must survive the relay +(`preview-oauth.ts:28-53,151-209,246-300`). Google is the first consumer; other gatekeepers can +adopt the leaf without porting to the assembly. + +### 4.17 `./action-files` + +```ts +export const ACTION_FILE_CHUNK_BYTES = 1024 * 1024; +export type ActionFileReference = { readonly handle: string; readonly size: number; + readonly digest: string }; +export type ActionFileStoreOptions = { readonly filePrefix: string; + readonly allocationPrefix: string; readonly maxFileBytes: number; + readonly maxTotalBytes: number }; +export type ActionFileStorage = { readonly kv: KvScannable; + transactionSync(callback: () => T): T }; +export class ActionFileStore { + constructor(storage: ActionFileStorage, options: ActionFileStoreOptions); + capture(bytes: Uint8Array): Promise; + read(file: ActionFileReference): Promise; + delete(file: ActionFileReference | undefined): void; + pruneUnreferenced(referencedHandles: ReadonlySet, createdBefore: number): void; +} +``` + +`ActionFileStore` keeps queued-action bytes out of action records as bounded, SHA-256-checked +one-MiB chunks. Capture writes the manifest, chunks, allocation, and aggregate accounting in one +synchronous transaction; deletion removes the same record family and releases its allocation in +one transaction (`action-files.ts:4-35,53-221`). It is consumed today +by google's `GmailForwardSnapshotStore` for exact inline-forward source snapshots +(`gmail-state.ts:5-34`; `gmail.ts:255-273,730-744`) and by `ConfluenceStore` for pending attachment +uploads, including orphan pruning and release after resolution +(`confluence-actions.ts:50-62,83-106,162-186,590-613`). ## 5. Layer 2: the assembly +**Layer-1 reconciliation, 2026-09-05 — the leaf contracts this section now builds on.** Observation +settlement is fail-closed by outcome: the kit defines and classifies a transport-stable +`OBSERVATION_REFUSED_CODE`, but **no producer exists yet** — `workshop-shared` owns no such code +and both of the overseer's pre-recording refusal paths still throw plain `Error`, so `discard` is +unreachable in production and every policy refusal fences permanently. Layer 2 must not assume +reclamation until that kernel change lands (§4.8). A marked refusal may discard prepared state, while an unknown +result abandons only in-memory claims and retains durable fences. Pending-set reclamation is +claim-counted per storage. On the next admission, a crash-stranded withheld-read marker is promoted +to the permanent fail-closed latch and removed. + +Credential ownership gained the provider-side `discardMint` drain for a successful refresh that +loses its identity fence; `"unadjudicated"` now surfaces the caller's original provider error +instead of synthesizing expiry, `CredentialSource.read()` exposes only a fresh identity/generation +fence, and every credential replacement — `connect()` and a successful refresh alike — re-arms the +expiry latch inside `#commit`. Action staging gained +the required per-set equality-only `ActionFence` policy, apply-time generation and git-cache context, and unresolved +reference guard; `AuthRetryOptions.replayable: true` makes the two-execution acknowledgment explicit. +Journal retirement is tombstone-first, with scans ignoring and a replayed apply healing any stale +record left by an interrupted removal. + +The smaller ownership seams landed with the same rule: provider cursors have a call-once disposal +hook, preview OAuth relays the standard callback error fields plus RFC 9207 `iss` and validated +extras, and named TTL caches isolate their entry and generation keyspaces while unnamed instances +retain the compatible shared namespace. + +A review pass over that change set closed four holes in it, all reachable rather than theoretical. +Set-marker reclamation now tracks, per disclosed key, how many reads still owe it and whether every +claimant refused: a sibling settling with an unknown outcome fences the marker for good — the naive +claim count let a later refusal delete a marker whose sibling may already have been recorded, which +would have admitted a collaborator against undisclosed data — and the last claimant to settle +reclaims, so two refused reads of one set no longer strand its slot. The expiry latch moved from +`connect()` into `#commit`, since a refresh that landed while a notification for the credentials it +replaced was in flight could let that notification latch it and silence its own death. Named caches +took a `cache:@:` prefix, because plain `cache::` let the name `entry` collide with the +unnamed layout. And `ProvisionalIds.isResolved` now classifies before consulting the binding table, +so the documented `isResolvedReference` spelling stops refusing an action whose `dependsOn` +reference is already a provider id. + +A second pass, from a consumer's perspective, closed the gaps the first one opened or left. A +terminal failure the apply refused *before* dispatch is the one state that is terminal, has a +`reject` hook, and provably never ran it, so the record now carries `undispatched` and the user's +rejection runs the hook for it — otherwise the rejection the failure message asks for silently +dropped whatever staging had set up, and `ActionFileStore`'s `pruneUnreferenced` sweep is a port's +own opt-in, not kit GC. Stranded dependents carry the same mark, since a cascade never reaches +their handlers either, and an `undispatched` record holds capacity instead of joining the prunable +set: only a rejection can release what its staging set up, so discarding the record strands those +artifacts for good, while blocking is visible and the user clears it by rejecting. The latch +re-arm moved again, out of +`#commit` for the legacy migration path only (`#publish`): moving a grant between storage layouts +replaces nothing, so re-arming there re-announced a death the account had already reported before +it was ported. The journal stores only the +`generation` an `ActionFence` declares, so handing `submit` a whole `CredentialRead` no longer +persists its identity fence. And a repeated custom `relayParams` key is refused like a repeated +built-in one, rather than appending each provider occurrence twice. + +Three consumer findings were adjudicated as **not defects**, and one fix was reverted as one. A +death decided inside `#refresh` cannot notify against a replacement's identity: only microtasks +separate that decision from `snapshot`'s fence read, and a `connect()` is delivered on an I/O turn, +so the proposed "capture before the await" would instead read a fence a reconnect had already moved +and silence a genuine death. `clearCredentialExpiryLatch` stays ahead of the credential write in +`#commit`, because both land in one implicit transaction and latch-first fails toward a duplicate +notice where credentials-first fails toward silence. A delayed rejection verdict cannot report a +live successor dead, because `#moved()` answers `"expired"` only with nothing stored and +`#notified` re-checks the fence after its await; the successor's dropped authority and fenced +in-flight fetch are the deliberate conservatism `#verdict` documents, costing one refetch. And the +eviction sweep in `retire()` was reverted to an obligation row (§4.8): it needs a tear +`ctx.storage.kv` cannot produce, and it had inverted the tombstone-first ordering to get there. + ### 5.1 `./spec` ```ts @@ -1790,6 +2167,8 @@ export interface AuthStrategy { obtain(ctx: { env: E; baseUrl: string; payload: unknown; metadata: AttemptMetadata; kv }): Promise; refresh?(creds: Creds, ctx: { env: E }): Promise; // CredentialsExpiredError on grant death only + heal?(creds: Creds, ctx: { env: E }): Promise; // mints past a rejected-but-current + // bearer (adjudicateRejection's refresh, §5.6); absent = grant death revoke?(creds: Creds, ctx: { env: E }): Promise; isAuthError(error: unknown): boolean; // runtime API classification (CredentialSource.run) expiredMessage: string; @@ -1833,7 +2212,7 @@ export function oauth2(config: { pkce?: boolean; // S256; verifier lives in the strategy's kv view, keyed by state exchange(ctx: { code: string; redirectUri: string; client: { id: string; secret: string }; env: E; codeVerifier?: string; requestedScopes?: string[] }): Promise; - refresh?; revoke?; isAuthError; expiredMessage; expiresAt?; refreshSkewMs?; + refresh?; heal?; revoke?; isAuthError; expiredMessage; expiresAt?; refreshSkewMs?; legacyKeys?: readonly string[]; upgradeStoredCredentials?; }): AuthStrategy; @@ -1903,8 +2282,8 @@ Public loopback-RPC methods and their sequencing: `options?.scopes === "auth"`; `putInitiation`; mints and stores a fresh random `"attemptGeneration"`; sets a `CONNECT_TIMEOUT_MS` self-destruct alarm when no credentials exist. -- `prepareReconnect(nonce)` — sets `"reconnecting"`, `clearCredentialExpiryLatch`, - `putInitiation`, fresh `"attemptGeneration"`. +- `prepareReconnect(nonce)` — sets `"reconnecting"`, calls `putInitiation`, and writes a fresh + `"attemptGeneration"`. - `beginAuth(nonce)` — `advanceToOAuth` with `{ connect }` metadata, then `strategy.begin`; after `begin`'s awaits, re-checks `"attemptGeneration"` and returns null on mismatch (rendered as an invalid link). @@ -1912,7 +2291,7 @@ Public loopback-RPC methods and their sequencing: returns false on mismatch. This closes the revoke race: a `revoke()` that ran during the token exchange has already cleared the generation, so the exchange result is discarded instead of resurrecting credentials after - `deleteAll()`. On success: `coordinator.connect`, `clearCredentialExpiryLatch`, clear + `deleteAll()`. On success: `coordinator.connect` (which re-arms the expiry latch), clear `"attemptGeneration"`; then `callback.credentialsRestored()` when reconnecting, else `callback.complete(mintUser())` — **and the credentials stay whatever that call does**; ephemeral sign-in accounts arm a 2-minute self-destruct alarm, everything else `deleteAlarm()`s. @@ -1929,26 +2308,42 @@ Public loopback-RPC methods and their sequencing: does not have and could not safely enable: sign-in replay mints a second session (`user.ts:416-426`) and, for cloudflare login, revokes the grant it is about to keep (`user.ts:1567-1590`). -- `getCredentials()` — `coordinator.fresh(strategy.refresh)`, projected through +- `getCredentials()` — `coordinator.snapshot(strategy.refresh, { notify })` with + `notify = () => notifyCredentialsExpiredOnce(kv, callback, spec.id)`, projected through `config.publicCredentials` and returned as `{ creds, identity, generation }` (the coordinator's current credential identity, reissued whenever credentials are written or cleared, plus its - `connectionGeneration()`). A still-current - `CredentialsExpiredError` from refresh triggers `noteCredentialsExpired()` and rethrows as a - `CredentialsExpiredError` carrying the strategy's `expiredMessage` — the name must survive the + `connectionGeneration()` — read synchronously together, which is `snapshot`'s whole job). A + still-current + `CredentialsExpiredError` from refresh awaits `notify` inside the helper and rethrows — the name + must survive the RPC (the transport strips the class), since the source drops its cache authority on it; verify preservation at the first port. Any other refresh error rethrows with credentials intact. **The projection is not optional — see below.** -- `noteCredentialsExpired(identity)` — no-ops unless `identity` matches the coordinator's - current one (a stale notifier lost the race to a reconnect); otherwise delegates to - `notifyCredentialsExpiredOnce` with `vendorId = spec.id`. +- `reportCredentialsRejected(identity)` — delegates to + `coordinator.adjudicateRejection(identity, { refresh, notify })` with the same `notify` and + `refresh = strategy.heal` (§5.2), the *explicit* rejection-heal callback. Presence of + `strategy.refresh` must not be the discriminator: it is the proactive expiry refresh, and a + provider can define it while a 401 on a current, unexpired bearer still means grant death + (supabase) — inferring would spend a doomed mint to answer what the grant-death path answers + directly. A derived-bearer strategy whose heal *is* its refresh wires `heal: refresh` + deliberately; grant-death providers leave it unset. The + moved-past gate answers `"superseded"` without notifying (a stale reporter lost the race to a + reconnect or a sibling's heal); a current identity heals through the fence-keyed `rotate()` or, + on confirmed death, notifies via `notifyCredentialsExpiredOnce` with `vendorId = spec.id` and + answers `"expired"`. The verdict adjudicates identity only: + the latch deliberately stays unset on a failed callback so a later expiry re-notifies, and + returning that failure would make the source resolve a dead grant as superseded — an endless + retry the user is never told about. - `revoke()` — clears `"attemptGeneration"`, `deleteAlarm()` and `deleteAll()` **before** the first await, then best-effort `strategy.revoke` on the grant it captured (failures log `error` with event `oauth.grant.revoke.failed`). Destroying local state after awaiting the provider would let - a connection begun during that await be erased by the revoke that preceded it. It also owns the - refresh in flight when it runs: the base hands the coordinator `strategy.refresh` wrapped so the - latest refresh promise is observable, and after `deleteAll()` it awaits that promise and - best-effort revokes its result too — a refresh that loses the identity fence otherwise mints - rotated provider-side authority nobody stored and nobody would ever revoke (§4.6 obligations). + a connection begun during that await be erased by the revoke that preceded it. It revokes only + the grant it captured: a mint that loses its identity fence — from a refresh or a rejection heal + alike — belongs to the coordinator's `discardMint`, which the base wires to a strategy hook for + disposing *one* mint, never to `strategy.revoke`: RFC 7009 lets a provider treat revoking one + refresh token as revoking the whole grant, which would kill the connection that just won + (§4.6). One owner, because a mint both drained and revoked here would be revoked twice at the + provider, and splitting ownership by which callback minted it reopens the leak either way. - `alarm()` — `deleteAll()` when no credentials exist or the account is ephemeral. Storage keys owned by the base: `"callback"`, `"nonce"`, `"reconnecting"`, `"expiredNotified"`, @@ -1993,30 +2388,34 @@ here, by wiring the two to one type — which is precisely why this is written d built. `KitUserAccountBase` gains the third parameter; where a gatekeeper has no refresh flow (github), `Public = Creds` is a legitimate instantiation, not a default to fall into. -**Deferred: the force-refresh channel (§4.13).** `getCredentials()` is `coordinator.fresh(...)`, -which refreshes on expiry only, so nothing in the base's RPC list reaches `coordinator.rotate()`. -A derived-bearer port therefore supplies `withAuthRetry`'s `getToken({ forceRefresh: true })` from -its own vendor RPC — google's `getAccessToken({ forceRefresh, staleToken })`, notion's -`refreshCredentials()`. Naming that channel here is what stops each port inventing one. Design -notes for whoever lands it: - -- **A required method, not an optional parameter.** TypeScript accepts a zero-argument - implementation as satisfying `getCredentials(options?: …)`, and jsrpc drops the argument at - runtime, so an account that ignores `forceRefresh` compiles and silently re-serves the rejected - token; `withAuthRetry` then replays it, `run`'s catch fires, and a healthy grant is retired. A - required `rotateBearer()` fails with TS2741 at the mistake, and has no option to ignore. -- **`staleBearer`, not `staleIdentity`.** `run` hands its callback `creds` only — `identity` stays - private — so an identity is unobtainable where this is wired. The rejected bearer is in scope by - construction, and comparing bearer values is what google already does (`google.ts:556`) to skip a - redundant mint. Required, since `withAuthRetry` always supplies it on the forced call. -- **Expiry gates first.** Google refuses any cached token inside the safety window whatever the - request asks for (`google.ts:555`); a forced rotate must not be answered with one either. -- **Do not widen `AccountCredentialStub`.** It would be dead surface for the five grant-death - providers. A free-standing type plus a small adapter over the bearer `run` already fetched keeps - the unforced path free of a second account round trip, which is the common case. -- **Interaction with the fencing row (§4.8).** `getCredentialsForGeneration(expected)` extends this - same seam on a *different* trigger (the first principal-switching port), and generation overlaps - with `staleBearer` semantically. Whichever lands first should leave room for the other. +**Landed 2026-09-03, superseded 2026-09-04**: this block shipped the force-refresh channel as +`CredentialSourceOptions.refreshCredentials`, a consumer-side option triggered by +`run(operation, { replayable: true })`. The 2026-09-04 inversion (§4.13) collapsed that channel +into the verdict protocol — the option and `ExpiryVerdict` no longer exist; the mint is the +`refresh` callback of `coordinator.adjudicateRejection`, and `reportCredentialsRejected` answers +`"expired" | "superseded" | "unavailable"`. The block is kept for the design-note resolutions +that still stand (the presence-check argument now applies to the account-side callback; the +staleness contract moved to `adjudicateRejection`'s doc): + +The composition the original deferral assumed — +`run(creds => withAuthRetry(...))` — routed the refresh around the reporter, so a rotating grant's +expiry was unreportable (§4.13). The kernels of its design notes that survive the collapse, in +the protocol's current vocabulary: + +- **Presence over required surface.** An optional method on an RPC stub cannot be + presence-checked (stubs are proxies that answer every property), and a required one is dead + surface for the five grant-death providers — which is why the heal is a local callback on + `adjudicateRejection`, never a stub method. The old safety throw (`replayable` without a wired + channel) is gone with the channel: an unwired heal now answers a current-identity rejection + `"expired"` honestly, so a grant-death port passing `replayable` is harmless (§4.13). +- **The port's mint logic stays the port's.** Redundant-mint skipping (`google.ts:556`) and the + expiry gate (`google.ts:555`) live inside the port's `refresh` callback; the identity the old + channel had to be handed is the report's own argument, adjudicated account-side. +- **Interaction with the fencing row (§4.8).** Closed as an opt-in leaf contract: + `BoundActionSet.submit` stores an opaque `ActionFence`, and apply compares the current generation + before dispatch. The source still refuses a retry whose refetch crossed a connection generation + and rethrows as `CredentialsChangedError`, so neither retry nor approved-action apply silently + crosses a connection when the port wires the fence. ### 5.7 `./vendor` — `KitVendorBase` @@ -2029,8 +2428,12 @@ DurableObjectNamespace<…> }`. Implements `describe()` (returns `spec.vendor` a ### 5.8 `./user` — `KitUserBase` Abstract `WorkerEntrypoint` with hook `[kitUserConfig](): { spec; exports(): -X; account(): AccountStub }`. The typed `exports()` closure is what lets the default -resolver call `def.facet(exports(), props)` without a cast. Implements: +X; account(): AccountStub }`. The typed `exports()` +closure is what lets the default resolver call `def.facet(exports(), props)` without a cast. +The consumer side carries no mint wiring *(2026-09-04: the `refreshCredentials` option this hook +previously threaded through is gone — the derived-bearer mint lives account-side behind +`coordinator.adjudicateRejection` (§4.13), inside the same stub the source already holds)*. +Implements: - `describe` / `getAuthenticatedEmail` via `spec.account.*` with a lazily built `AccountHandle` (a `CredentialSource` over `account()`). @@ -2052,13 +2455,18 @@ resolver call `def.facet(exports(), props)` without a cast. Implements: ### 5.9 `./facet` — `KitGatekeeperBase` Abstract `DurableObject` with hook `[kitFacetConfig](): { spec; resource: -ResourceDef<…>; observers: ObserverStrategy; actions?: BoundActionSet }`, invoked per call so +ResourceDef<…>; observers: ObserverStrategy; creds: CredentialSource; +actions?: BoundActionSet }`, invoked per call so the hook can branch on `this.ctx.props` (supabase: project bindings return the project def and -`aclObservers`, organization bindings the organization def and `trackedSetObservers`). Implements +`aclObservers`, organization bindings the organization def and `trackedCollectionObservers`). The facet +already holds the source to run its provider calls through, so naming it here is what lets the +base fence an apply without a second way to reach the account. Implements `getTypeScriptTypes` (`resource.types ?? spec.types`), `getAutoApprovableActions` -(`actions?.autoApprovableKinds() ?? []`), `applyAction`/`rejectAction` (dispatch straight to +(`actions?.autoApprovableKinds() ?? []`), `applyAction`/`rejectAction` (dispatch to `actions`, which already serializes both on the queue it owns — §4.8 — and throwing when no actions -are configured), `addObserver`/`removeObserver` (delegating to the strategy), a protected +are configured; `applyAction` passes `{ gitCache: cache, generation }` from +`(await creds.read()).generation`, since a fenced record refuses to apply without one), +`addObserver`/`removeObserver` (delegating to the strategy), a protected `observationGate(queue)` helper that `.dup()`s the queue and binds the strategy — the session's **only** dup: action submission borrows the same stub through `gate.actions` (§4.7), matching the corpus's one-stub-per-session shape (`supabase.ts:814-819`) — and a protected @@ -2072,11 +2480,11 @@ abstract — resource metadata lookups and the session API are the gatekeeper `observers` both carry in-memory state that is the whole point of them: `BoundActionSet` owns the `SerialTaskQueue` every resolution is ordered on plus the `claimedHere` set, and `ObserverTracker` owns the admission/removal fence. A hook calling `defineActions(...).bind(...)` or -`trackedSetObservers(...)` inline — the shape a per-call hook invites — would hand every call a +`trackedCollectionObservers(...)` inline — the shape a per-call hook invites — would hand every call a fresh queue and empty sets, silently voiding both guarantees. `bind` blunts its likeliest form by being idempotent per journal: rebinding a module-scoped set to a facet-held journal returns the first bound set, so even the per-call shape shares one queue. Nothing equivalent covers -`trackedSetObservers`, and a hook that rebuilds the set or the journal per call stays uncatchable +`trackedCollectionObservers`, and a hook that rebuilds the set or the journal per call stays uncatchable — so the hook resolves these from instance fields, built once per activation and memoized per `ctx.props` when a facet serves more than one resource kind; the base's own doc comment says so, and the fixture asserts two concurrent `applyAction` calls share one queue. @@ -2196,17 +2604,20 @@ Each step leaves the tree building; tests land with the module they cover. Nothi `packages/gatekeeper-kit` changes before step 12. 1. **Scaffold the package.** `package.json` (name `@gadgets/gatekeeper-kit`, private, `type: - module`, per-file `exports` map for every module in §4/§5; scripts `build`, `clean`, and - `test:run: "vitest run && vitest run -c vitest.worker.config.ts"` as a direct script beside - the cached Vite `test` task; dependencies `@gadgets/workshop-shared`, - `@gadgets/backend-utils` (both `workspace:*`); devDependencies - `@cloudflare/vitest-pool-workers`, `typescript`, `vitest`, all `catalog:`). As landed the + module`, per-file `exports` map for every module in §4/§5; scripts + `test:run: "vitest run && vitest run -c vitest.worker.config.ts"`, + `test:watch:node: "vitest"`, and + `test:watch:workerd: "vitest -c vitest.worker.config.ts"`; dependencies + `@gadgets/workshop-shared` and `@gadgets/backend-utils` (`workspace:*`) plus `jose`; devDependencies + `@cloudflare/vitest-pool-workers`, `@cloudflare/workers-types`, `typescript`, and `vitest` + (`catalog:`) plus `@gadgets/scripts` (`workspace:*`)). `build` (`tsc`) and `clean` + (`rm -rf dist`, uncached) are Vite+ tasks, not package scripts. As landed the scaffold is deliberately leaner than first sketched: **one** `tsconfig.json` covering `src` and `__tests__` on `@cloudflare/workers-types/experimental` — no `tsconfig.test.json` and no checked-in `worker-configuration.d.ts` to drift — and no `capnweb`, `capnweb-validate`, or `@types/node`, since Layer 1 has no capnweb runtime path; those arrive when the Layer-2 - fixture needs them. `vite.config.ts` re-exports the shared vitest task: - `vitestTaskViteConfig('pnpm test:run')`. Run `pnpm install`. + fixture needs them. `vite.config.ts` uses `withVitestTask` for the two Vitest commands and adds + the Vite+ `build`/`clean` tasks above. Run `pnpm install`. 2. **`connect-nonce`, `connect-handshake`, `connect-pages`, `endpoint` (§4.1–4.3, §4.14).** workerd tests: nonce round-trip and TTL expiry; stage transitions; exactly one concurrent `advanceToOAuth` succeeds per attempt; a wrong initiation nonce does not consume the attempt; @@ -2226,18 +2637,19 @@ Each step leaves the tree building; tests land with the module they cover. Nothi late-resolving callback); `clearCredentialExpiryLatch` re-arms. 4. **`http-errors` + `observers` (§4.5, §4.7).** Node tests with a Map-backed KV stub and fake verifiers: 401/403/404 classify as no-access and 5xx rethrows; a throwing `verifyBaseline` - propagates before any `hasSetAccess` call; + propagates before any `hasCollectionAccess` call; re-read-until-stable admission (a set appearing mid-check is verified before the verifier persists); batched oracle called once per admission round; a legacy stored `true` reads as - observed and re-reading it is not a fresh reveal; an overlapping `setPrefix` is refused in + observed and re-reading it is not a fresh reveal; an overlapping `collectionPrefix` is refused in either direction; per-set deny messages; pending-before-await then commit promotion; forward exclusion lists exactly the observers lacking access, and excludes one whose verifier throws rather than failing the read; `removeObserver` idempotence, and a removal mid-admission refusing the admission; `ObservationGate` ordering - (`prepare` → `authorizeObservation` with `excludeObservers` → `commit`; no commit when - authorization throws); `escapeObservationValue` flattening each newline run to one space and - escaping every control character while leaving prose and the empty string alone; each scope arm's - exclusions, an empty `sets` scope being refused, and a `baseline` read delivering the caller's + (`prepare` → `authorizeObservation` with `excludeObservers` → `commit`; marked refusal → + `discard`, unmarked failure → `abandon` with durable fences retained); + `escapeObservationValue` flattening each newline run to one space and escaping every control + character while leaving prose and the empty string alone; each scope arm's exclusions, an empty + `sets` scope being refused, and a `baseline` read delivering the caller's own object with the oracle never consulted. 5. **`credentials` (§4.6).** Node tests: skew-aware reuse; two concurrent `fresh` calls share one refresh; a `connect` (reconnect) during an in-flight refresh wins and `fresh` returns the newer @@ -2253,11 +2665,39 @@ Each step leaves the tree building; tests land with the module they cover. Nothi `CredentialSource`: two concurrent `get`s make one account round trip and the next sequential one re-reads, and an auth failure drops the in-flight fetch so the next caller does not receive credentials already reported expired. For - `withAuthRetry` (§4.13): the success path asks for a token once with `forceRefresh: false`; a - non-auth error at either attempt propagates with no refresh and no report; an auth error - refreshes with `{ forceRefresh: true, staleToken }` and returns the replay's result; two auth - errors surface the second one; and when composed under `CredentialSource.run`, the outer source - reports it exactly once. + `withAuthRetry` (§4.13): the required `replayable: true` acknowledges the operation may run + twice; the success path asks for a token once with `forceRefresh: false`; a non-auth error at + either attempt propagates with no refresh and no report; an auth error refreshes with + `{ forceRefresh: true, staleToken }` and returns the replay's result; and two auth errors surface + the second one. For the verdict protocol (§4.13), a rejection is reported against the identity + the failed attempt used, and the verdict decides — `"expired"` throws + `CredentialsExpiredError(expiredMessage)` with the identity marked dead, `"superseded"` throws + `CredentialsChangedError` or, under `replayable`, refetches and retries once; `"unavailable"` or + an internal `"unadjudicated"` result from a malformed or lost answer rethrows the caller's + original provider error without dead-marking the identity; + the retry is refused as "changed" when its refetch crosses a generation, re-serves the + rejected identity, or was not itself adopted (a fenced-out refetch triggers neither the + re-serve's authority drop nor the dead successor's expiry — both act only on the read the + source last stood behind), resolved as expiry without a provider call when the successor the + source stands behind is already dead, and its own rejection is adjudicated but never retried + (at most two executions); a live + successor adopted mid-operation resolves the failure as "changed" with no ask spent and the + live authority kept — before the first ask and at the retry's rejection alike; the rejected + authority drops at the ask (cache-first readers bypass during the round trip instead of + serving the rejected partition) and a read landing mid-ask is served but never adopted — + the pending ask blocks re-adopting the identity whose verdict is out — then drops again at + the verdict, with the death mark waiting for an `"expired"` answer; asks coalesce per identity, so a burst of rejections of one grant spends one report + and one refetch; and the coordinator halves hold their own contracts — `snapshot`'s triple is + atomic against a connect landing at the await boundary and notifies only a still-stored + grant's confirmed death, `adjudicateRejection` gates moved-past identities ("" never matches) + before healing through the fence-keyed rotate (concurrent heals share one mint), answers + `"superseded"` when a reconnect overtakes the mint, notifies before `"expired"`, and answers + `"unavailable"` with credentials intact when the mint fails for non-credential reasons — + proven composed by an integration suite (real coordinator over `fakeKv` behind a real source): + an invisible heal spending one mint and no notification, a dead grant under concurrent runs + spending one mint and one notification, a non-replayable stale bearer re-entering with no + second mint, a mid-operation reconnect resolving as "changed" with no mint, and one mint + however many facets report their stale bearers. 6. **`actions` (§4.8).** Node tests: sequential IDs; staged→pending transitions; the default keys landing records at `pending:action:` with counter `pending:nextActionId` (a live-storage contract for the supabase/google-family ports, so those literals are @@ -2278,8 +2718,10 @@ Each step leaves the tree building; tests land with the module they cover. Nothi lifecycle (§4.8): `listPending` projects a `claimed` record and not a `failed` one; no transition moves a settled record and the first stored failure message wins; a stored `failed` record that lost its reason still reads with one; `maxPending` - refuses `allocate` and `submit` at the cap while writing nothing, and a `failed` record neither - counts against it nor ever blocks a new action — while the prunable tier is bounded at twice the + refuses `allocate` and `submit` at the cap while writing nothing, and an ordinary `failed` + record neither counts against it nor ever blocks a new action — while an `undispatched` one + does both, holding its slot until a rejection releases the staging artifacts only that + rejection can free, and never joining the prunable tier; that tier is bounded at twice the cap, drops nothing under it, and takes a stranded `staged` record before an explained failure; a prototype-inherited kind (`constructor`, `toString`, `valueOf`, `hasOwnProperty`) takes the dropped-kind path instead of resolving to an inherited handler; a bare reference string is @@ -2354,7 +2796,10 @@ Each step leaves the tree building; tests land with the module they cover. Nothi dispatched through the queue (interleaving asserted against a concurrent apply), bound with `retainApplied: true` so its record survives apply, and firing `afterResolve("reverted")`; the facet-base assert rejects (named config error) a revert hook whose actions don't retain; - a stale-identity `noteCredentialsExpired` after a reconnect no-ops; with the + a stale-identity `reportCredentialsRejected` after a reconnect answers `"superseded"` without + notifying, and a current-identity one answers `"expired"` even when the Workshop callback + fails (the latch stays unset for a later re-notify; the verdict adjudicates identity only) — + both through the real account RPC; with the hook absent, `revertAction` throws not-implemented; strategy-B observer denial. This suite is also the proof that decorated subclasses of the kit's generic bases survive the `capnweb-validate` transform. @@ -2406,8 +2851,8 @@ Each step leaves the tree building; tests land with the module they cover. Nothi `GatekeeperUserImpl`, `SupabaseGatekeeperImpl`; `SupabaseVerifier` untouched), and a default export wiring `handleGatekeeperHttp`. - `SupabaseSessionContext` (:814-913) survives, rebuilt on kit pieces: `ObservationGate` - (project bindings `aclObservers`, organization bindings `trackedSetObservers` with - `setPrefix: "observedProject:"` and `verifyBaseline` throwing the existing org-membership + (project bindings `aclObservers`, organization bindings `trackedCollectionObservers` with + `collectionPrefix: "observedProject:"` and `verifyBaseline` throwing the existing org-membership denial — the legacy stored `true` needs no flag — denial messages preserved verbatim from :1152-1179), `BoundActionSet.submit` (the SQL `ActionDescription` text preserved verbatim from :896-907), `KvTtlCache`, and @@ -2419,10 +2864,16 @@ Each step leaves the tree building; tests land with the module they cover. Nothi clear local failure instead of a call carrying a provisional id. Confluence is the corpus precedent for doing both (`confluence-actions.ts:438-443`, `:571-600`). - The facet keeps `describe()` per resource kind (:1045-1068) and `startSession` (:1078-1084). - Actions: `defineActions` whose - `apply` preserves :1096-1108 (auth failure notes expiry and throws the "reconnect, then - retry" message without removing the record), with `afterResolve` bumping the cache - generation on `"applied"`. No `revert` hook and `retainApplied` unset, so records are + Actions: `defineActions` whose `apply` + runs the statement inside `CredentialSource.run`, so an auth failure is adjudicated by the + account (§4.6) rather than noted through the removed fire-and-forget path; `"expired"` + surfaces as `CredentialsExpiredError` carrying supabase's existing "reconnect, then retry" + text, and the record stays pending either way, exactly as :1096-1108 leaves it. Submission + passes `{ fence: read }` from the staging operation's own `CredentialRead`, and the handler + compares `ctx.fence.generation` against the read its `run` callback receives before issuing + SQL: the base's apply-time check is an entry gate, and a reconnect landing after it would + otherwise run approved SQL against the replacement connection (§4.8). `afterResolve` bumps + the cache generation on `"applied"`. No `revert` hook and `retainApplied` unset, so records are removed on apply (the facet-base assert is trivially satisfied) — storage byte-identical to today. The dead-code compensating-statement message (:1120-1126) is intentionally dropped: the path is @@ -2437,7 +2888,9 @@ Each step leaves the tree building; tests land with the module they cover. Nothi `upgradeStoredCredentials` (legacy keys convert and are deleted) and the journal's legacy-record upgrade; a workerd `connect-flow.test.ts` against the real `UserAccount` subclass (single-use initiation advance under concurrency, wrong-nonce rejection without - consuming the attempt, wrong-state `completeAuth` rejection), with its own + consuming the attempt, wrong-state `completeAuth` rejection) plus a fenced-apply case (SQL + approved under one connection refuses to run after a reconnect, both at the base's entry check + and at the handler's own comparison), with its own `vitest.worker.config.ts` (`capnwebValidate` + `cloudflareTest` with the `UserAccount` DO + `assert-workerd`) and `__tests__/env.d.ts`. Switch `vite.config.ts` to the `withTests` re-export from `scripts/gatekeeper-configurator-vite-config.js` and add `test:run` plus the @@ -2448,15 +2901,17 @@ Each step leaves the tree building; tests land with the module they cover. Nothi 16. **Skill rewrite.** `.agents/skills/write-gatekeeper/SKILL.md` keeps the seven responsibilities, the phase gates (including the API-design STOP), and the observer taxonomy; Phase 1 becomes kit-first (spec + `types.d.ts` + sessions), Phase 2 maps strategies A–D to - `privateObservers`/`aclObservers`/`trackedSetObservers`/`openObservers`, actions to + `privateObservers`/`aclObservers`/`trackedCollectionObservers`/`openObservers`, actions to `defineActions` + `ActionJournal` + `stageAction`, and simulation to the pure substrate (`createSimulationView` over `journal.listPending()`, `replaySimulation`, `ProvisionalIds`, provider reducers local and pure). Revert guidance: the facet's `protected revert(id)` hook with github's and linear's `revertAction` bodies as the exemplars. Recipes, cited by symbol name (never line numbers — those rot): cascade rejection of provisional dependents (linear's dependent-action sweep in `rejectAction`, github's `#rejectReplyDependencyChain`) and - apply-time credential failure (wrap apply bodies in `CredentialSource.run`, the supabase - `noteCredentialsExpired` mapping). A new "when to bypass the kit" section names the known + apply-time credential failure (wrap apply bodies in `CredentialSource.run`, mapping the + provider's grant-death error to `CredentialsExpiredError` and reporting a rejection through + `adjudicateRejection` — not the pre-kit fire-and-forget `noteCredentialsExpired` note, which + the verdict replaces). A new "when to bypass the kit" section names the known cases — google-class OAuth irregularities, MCP-class runtime-generated types, email-class resource claiming — and states that each keeps implementing the raw interfaces while reusing leaf modules. Reference implementations: supabase for the kit path, github for the raw path. @@ -2511,9 +2966,8 @@ All commands from the repo root. superset of the old one, so live initiation links keep working; a flow whose state was minted before the deploy and consumed after may fail once, and the user restarts the connect. No migration code for attempt records. -- **Vite+ task nesting**: if `vitestTaskViteConfig('pnpm test:run')` misbehaves under vp's - stripped environment, give the task the composed string - `vitest run && vitest run -c vitest.worker.config.ts` directly, as +- **Vite+ task nesting**: if `withVitestTask` misbehaves under vp's stripped environment, give its + test task the composed string `vitest run && vitest run -c vitest.worker.config.ts` directly, as `gatekeeper-cloudflare`'s `test:run` script composes it. ## 10. Deferred seams — separate implementations behind existing interfaces From 44f7950acb543cef021991a7969ff82f32ec48ed Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:10:14 -0500 Subject: [PATCH 05/15] Bind gatekeeper connect completion to the initiating browser (#464) * Bind gatekeeper connect completion to the initiating browser A gatekeeper connect URL is a bearer capability: whoever finishes OAuth at it has their provider tokens delivered into the account that started the flow, and nothing tied the completing browser to that user. An attacker could start a connect and phish a victim into opening the URL. The gatekeeper's final page now posts a single-use ticket to the window that opened it, targeted at the Workshop's origin from PUBLIC_BASE_URL, and the Workshop activates the grant only when that ticket is redeemed over the initiating user's own session. Kernel changes: - GatekeeperConnectCallback.complete() stages the account in the user's DO under the SHA-256 of a fresh 256-bit ticket and returns the ConnectHandoff instead of persisting the account. - New GatekeeperConnectCallback.reconnectComplete() for reconnect and ensureResources flows, whose credentials the gatekeeper now stages until the Workshop calls the new GatekeeperUser.commitReconnect(). credentialsRestored() stays for out-of-band refresh recovery. - AuthenticatedApi.completeConnectHandoff(ticket) redeems a ticket in the caller's own DO (single use, two-minute lifetime); an alarm revokes staged connects whose ticket never came back. - LoginConnectCallbackImpl mints the ticket but does not yet enforce it; the sign-in takeover is a documented follow-up. - PUBLIC_BASE_URL is now required for connects; the dev server defaults it to the frontend's origin. --- AGENTS.md | 2 +- docs/oauth-signin.md | 25 +- packages/gatekeeper-cloudflare/package.json | 1 + .../gatekeeper-cloudflare/src/cloudflare.ts | 81 ++- .../gatekeeper-confluence/src/confluence.ts | 76 ++- .../src/library-gatekeeper.ts | 3 + packages/gatekeeper-email/package.json | 1 + packages/gatekeeper-email/src/email.ts | 39 +- .../__tests__/github-api.test.ts | 23 + packages/gatekeeper-github/package.json | 1 + packages/gatekeeper-github/src/github-api.ts | 11 +- packages/gatekeeper-github/src/github.ts | 88 ++- packages/gatekeeper-google/src/google.ts | 81 ++- packages/gatekeeper-google/tsconfig.json | 2 +- .../gatekeeper-homeassistant/package.json | 1 + .../src/homeassistant.ts | 88 ++- .../__tests__/connect-pages.test.ts | 101 +++- .../__tests__/credential-stage.test.ts | 96 +++ packages/gatekeeper-kit/package.json | 1 + packages/gatekeeper-kit/src/connect-pages.ts | 107 +++- .../gatekeeper-kit/src/credential-stage.ts | 117 ++++ packages/gatekeeper-linear/package.json | 1 + packages/gatekeeper-linear/src/linear.ts | 72 ++- packages/gatekeeper-mcp-portal/src/portal.ts | 4 +- packages/gatekeeper-mcp/src/mcp.ts | 4 +- packages/gatekeeper-notion/package.json | 1 + packages/gatekeeper-notion/src/notion.ts | 69 ++- .../gatekeeper-scheduler/src/scheduler.ts | 5 + packages/gatekeeper-slack/package.json | 1 + packages/gatekeeper-slack/src/slack.ts | 96 +-- packages/gatekeeper-slack/tsconfig.json | 2 +- packages/gatekeeper-spotify/package.json | 1 + packages/gatekeeper-spotify/src/spotify.ts | 91 ++- packages/gatekeeper-supabase/package.json | 1 + packages/gatekeeper-supabase/src/supabase.ts | 104 +++- packages/gatekeeper-zoominfo/package.json | 1 + packages/gatekeeper-zoominfo/src/zoominfo.ts | 94 ++- .../__tests__/workshop-blueprints.test.ts | 5 +- .../gatekeeper-test/src/test-gatekeeper.ts | 4 + .../__tests__/account-endpoint.test.ts | 571 ++++++++++++++++-- packages/mcp-shared/__tests__/http.test.ts | 29 +- packages/mcp-shared/__tests__/user.test.ts | 5 + packages/mcp-shared/package.json | 1 + packages/mcp-shared/src/account.ts | 413 ++++++++++--- packages/mcp-shared/src/html.ts | 93 +-- packages/mcp-shared/src/http.ts | 14 +- packages/mcp-shared/src/user.ts | 7 + .../__tests__/connect-handoff.test.ts | 301 +++++++++ .../__tests__/pending-login.test.ts | 233 +++++++ .../workshop-backend/__tests__/test-worker.ts | 54 ++ .../workshop-backend/src/auth/login-flow.ts | 201 ++++-- .../workshop-backend/src/connect-handoff.ts | 40 ++ .../workshop-backend/src/observability.ts | 1 + packages/workshop-backend/src/server.ts | 16 +- packages/workshop-backend/src/user.ts | 213 ++++++- packages/workshop-backend/vitest.config.ts | 9 +- .../src/BlueprintLandingPage.test.tsx | 70 ++- .../src/BlueprintLandingPage.tsx | 20 +- .../src/ConnectAccountModal.tsx | 105 ---- .../src/ConnectHandoffListener.tsx | 18 + .../workshop-frontend/src/GatekeeperModal.tsx | 13 +- .../src/ObserverConfigModal.test.tsx | 28 +- .../src/ObserverConfigModal.tsx | 7 +- .../src/OnboardingWizard.tsx | 3 +- .../workshop-frontend/src/ResourcePicker.tsx | 9 +- .../src/components/auth/OAuthButtons.test.tsx | 187 ++++++ .../src/components/auth/OAuthButtons.tsx | 150 +++-- .../components/billing/OutOfCreditsModal.tsx | 11 +- .../src/components/billing/UsageSettings.tsx | 3 +- .../src/connectHandoff.test.tsx | 296 +++++++++ .../workshop-frontend/src/connectHandoff.ts | 186 ++++++ packages/workshop-frontend/src/main.tsx | 10 +- .../workshop-frontend/src/routes/__root.tsx | 2 + .../src/routes/gatekeepers.tsx | 7 +- packages/workshop-shared/src/api.ts | 53 +- packages/workshop-shared/src/gatekeeper.ts | 128 +++- pnpm-lock.yaml | 33 + scripts/run-dev-server.ts | 9 + 78 files changed, 4171 insertions(+), 879 deletions(-) create mode 100644 packages/gatekeeper-kit/__tests__/credential-stage.test.ts create mode 100644 packages/gatekeeper-kit/src/credential-stage.ts create mode 100644 packages/workshop-backend/__tests__/connect-handoff.test.ts create mode 100644 packages/workshop-backend/__tests__/pending-login.test.ts create mode 100644 packages/workshop-backend/__tests__/test-worker.ts create mode 100644 packages/workshop-backend/src/connect-handoff.ts delete mode 100644 packages/workshop-frontend/src/ConnectAccountModal.tsx create mode 100644 packages/workshop-frontend/src/ConnectHandoffListener.tsx create mode 100644 packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx create mode 100644 packages/workshop-frontend/src/connectHandoff.test.tsx create mode 100644 packages/workshop-frontend/src/connectHandoff.ts diff --git a/AGENTS.md b/AGENTS.md index 9e8269dee4..147ac7d621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The project structure is: * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. * packages/gatekeeper-*: Gatekeeper workers for external service integrations. * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens. - * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. + * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml` (which posts a single-use ticket back to the Workshop — over a same-origin BroadcastChannel, since connect popups are disowned before navigation, or to its opener for sign-in) and a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone. * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page, and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither. * packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README. * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`. diff --git a/docs/oauth-signin.md b/docs/oauth-signin.md index 1e9d602a9e..37f66b5e0c 100644 --- a/docs/oauth-signin.md +++ b/docs/oauth-signin.md @@ -38,13 +38,18 @@ what persists a usable connected account. `GatekeeperVendor.connectAccount` take `PendingLogin` DO, hands the gatekeeper a `LoginConnectCallbackImpl`, and returns the gatekeeper's OAuth `url` plus an `attempt` stub (a capability wrapping the `PendingLogin` DO — no login id is exposed to the client). -2. The client opens `url` in a pop-up (the gatekeeper's self-closing OAuth window) and calls - `attempt.wait()`, which blocks on the `PendingLogin` DO. +2. The client opens `url` as a pop-up, keeping itself as the pop-up's opener. 3. When the gatekeeper finishes, it calls `complete(user)`. The callback reads `user.getAuthenticatedEmail()`, resolves/creates the email-keyed `UserDurableObject`, mints a - session, and delivers the `":"` token to the `PendingLogin` DO — which resolves the - awaiting RPC. -4. The client stores the token and authenticates as usual. + session, and parks the `":"` token in the `PendingLogin` DO under the hash of a + fresh handoff ticket. `complete()` returns that ticket, and the gatekeeper's final page posts it to + its opener — exactly as the connect-account flow does (`connectHandoffPageHtml` in gatekeeper-kit). +4. The opener calls `attempt.claim(ticket)`; the `PendingLogin` DO releases the token only for the + matching ticket, once. This is what binds the session to the browser that started the attempt: + the sign-in URL is a bearer capability, so whoever holds `attempt` without the ticket (an attacker + who phished a victim into finishing the flow) gets nothing, and the unclaimed token is wiped after + two minutes. +5. The client stores the token and authenticates as usual. Sign-in does **not** persist a connected account: the minimal-scope grant is only used to read the email and is then discarded by the gatekeeper. To use a gatekeeper's capabilities (repos, Gmail/Docs) @@ -73,10 +78,10 @@ In local dev, `run-dev-server.ts` seeds each gatekeeper's `CLIENT_ID`/`CLIENT_SE ## Storage / bindings -- `PendingLogin` (DO) — short-lived bridge between a gatekeeper login pop-up and the waiting browser, - reached via `ctx.exports` (no explicit binding). Holds no durable storage: the in-flight - `attempt.wait()` keeps it alive, and it's evicted once the login completes or the client disposes - the `attempt` stub. +- `PendingLogin` (DO) — short-lived bridge between a gatekeeper login pop-up and the browser that + started the attempt, reached via `ctx.exports` (no explicit binding). Stores the delivered token + under the ticket's hash until `claim()` consumes it; an alarm wipes an unclaimed result after two + minutes. ## Code layout @@ -88,4 +93,4 @@ auth/ ``` Client-side: `ServerConfigContext` exposes `authVendors` and `passwordAuthEnabled`; -`components/auth/OAuthButtons` renders the sign-in options (pop-up + `attempt.wait()`). +`components/auth/OAuthButtons` renders the sign-in options (pop-up + handoff ticket + `attempt.claim()`). diff --git a/packages/gatekeeper-cloudflare/package.json b/packages/gatekeeper-cloudflare/package.json index 3fca2b8338..744271e522 100644 --- a/packages/gatekeeper-cloudflare/package.json +++ b/packages/gatekeeper-cloudflare/package.json @@ -12,6 +12,7 @@ "dependencies": { "@gadgets/backend-utils": "workspace:*", "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-cloudflare/src/cloudflare.ts b/packages/gatekeeper-cloudflare/src/cloudflare.ts index 6e13736d5c..1d483911c8 100644 --- a/packages/gatekeeper-cloudflare/src/cloudflare.ts +++ b/packages/gatekeeper-cloudflare/src/cloudflare.ts @@ -4,8 +4,10 @@ import { GatekeeperVendor as GatekeeperVendorIface, Gatekeeper, GatekeeperUserVerifier, VendorDescription, GatekeeperConnectCallback, GatekeeperConnectOptions, AccountDescription, SupportedResource, ResourceConfiguratorFrame, ResourceDescription, ApprovalQueue, ActionKind, - GitCache, stripTrailingSlashes, + GitCache, stripTrailingSlashes, type ConnectHandoff, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { CloudflareGatekeeperUser } from "@gadgets/workshop-shared/cloudflare-gatekeeper"; import { getOAuthConfig, buildAuthorizeUrl, generatePkce, exchangeCode, refreshTokens, @@ -44,12 +46,20 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; verifier?: string; scopes?: string[]; }; // A cached access token plus its absolute expiry (unix ms). type StoredAccessToken = { token: string; expires: number }; +/** The live keys a completed OAuth exchange writes, as one value so a reconnect can stage it. */ +type StoredGrant = { refreshToken: string; accessToken: StoredAccessToken; grantedScopes: string[] }; const NONCE_BYTES = 32; const INITIATION_NONCE_LIFETIME_MS = 10 * 60 * 1000; @@ -97,12 +107,6 @@ function getBasePath(env: Env) { return path === "/" ? "" : path; } -const SELF_CLOSING_HTML = ` - - -

Authorization complete. You may close this tab and return to Cloudflare OS. -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -157,10 +161,11 @@ export default { if (!code) return new Response("Error: no 'code' provided"); const stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId)); - if (!await stub.acceptAuthCode(code, oauthNonce)) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); }, @@ -232,12 +237,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string, scopes: string[]) { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("scopes", scopes); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -262,6 +267,7 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, verifier, }); // Fail closed: a missing `scopes` key is legacy or corrupted state, so request only the billing @@ -270,11 +276,15 @@ export class UserAccount extends DurableObject { return { oauthNonce, challenge, scopes }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || !stored.verifier || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -288,26 +298,26 @@ export class UserAccount extends DurableObject { throw new Error("Cloudflare OAuth exchange failed or returned no refresh token."); } - this.ctx.storage.kv.put("refreshToken", tokens.refreshToken); - this.ctx.storage.kv.put("accessToken", { - token: tokens.accessToken, - expires: Date.now() + tokens.expiresIn * 1000, - }); // Fail closed for the same reason as `beginOAuthFlow`: recording the full scope list here when // the provider omitted `scope` would advertise an observability grant that was never made, and // `ensureResources` would then short-circuit into a binding that 403s with no way to fix it. - this.ctx.storage.kv.put( - "grantedScopes", - tokens.scopes ?? this.ctx.storage.kv.get("scopes") ?? [...BILLING_SCOPES], - ); + const grant: StoredGrant = { + refreshToken: tokens.refreshToken, + accessToken: { token: tokens.accessToken, expires: Date.now() + tokens.expiresIn * 1000 }, + grantedScopes: tokens.scopes ?? this.ctx.storage.kv.get("scopes") ?? [...BILLING_SCOPES], + }; - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.#writeGrant(grant); try { - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props: { userObjectId: this.ctx.id.toString() } })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props: { userObjectId: this.ctx.id.toString() } })); } catch (err) { this.ctx.storage.kv.delete("refreshToken"); throw err; @@ -319,7 +329,20 @@ export class UserAccount extends DurableObject { this.ctx.storage.setAlarm(Date.now() + 2 * 60 * 1000); } } - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#writeGrant(grant); + } + + #writeGrant(grant: StoredGrant) { + this.ctx.storage.kv.put("refreshToken", grant.refreshToken); + this.ctx.storage.kv.put("accessToken", grant.accessToken); + this.ctx.storage.kv.put("grantedScopes", grant.grantedScopes); } hasRefreshToken() { @@ -469,6 +492,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#account().commitReconnect(stageId); + } + @skipRpcValidation() async getVerifier(): Promise> { return this.ctx.exports.CloudflareVerifier({ diff --git a/packages/gatekeeper-confluence/src/confluence.ts b/packages/gatekeeper-confluence/src/confluence.ts index 2e11f1ca43..52138fa0ac 100644 --- a/packages/gatekeeper-confluence/src/confluence.ts +++ b/packages/gatekeeper-confluence/src/confluence.ts @@ -19,6 +19,7 @@ import { skipRpcValidation, validateRpc } from "capnweb-validate"; import { type AccountDescription, type ApprovalQueue, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperConnectOptions, @@ -31,6 +32,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { CONFLUENCE_SCOPES, ConfluenceApi, @@ -128,7 +131,17 @@ type Env = Cloudflare.Env & { CLIENT_SECRET?: string; }; -type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth" }; +type StoredNonce = { + value: string; + expiresAt: number; + stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; +}; type StoredGrant = { accessToken: string; refreshToken?: string; expiresAt: number }; const getBaseUrl = (env: Env): string => env.BASE_URL || "http://localhost:8787/gatekeeper/confluence"; @@ -165,13 +178,6 @@ const PAGE_RESOURCE: SupportedResource = { }; const SUPPORTED_RESOURCES = [SITE_RESOURCE, SPACE_RESOURCE, PAGE_RESOURCE]; -const htmlResponse = (body: string): Response => - new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } }); - -const SELF_CLOSING_HTML = ` - -

Authorization complete. You may close this tab and return to Cloudflare OS.

`; - const page = (title: string, color: string, message: string): string => ` ${title} @@ -225,8 +231,9 @@ export default { if (colonIdx < 0) return new Response("Error: malformed state", { headers: { "content-type": "text/plain; charset=utf-8" } }); const stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(state.slice(0, colonIdx))); - if (!await stub.acceptAuthCode(code, state.slice(colonIdx + 1))) return htmlResponse(INVALID_LINK_HTML); - return htmlResponse(SELF_CLOSING_HTML); + const handoff = await stub.acceptAuthCode(code, state.slice(colonIdx + 1)); + if (!handoff) return htmlResponse(INVALID_LINK_HTML); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); }, @@ -284,9 +291,11 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string) { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("nonce", { - value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + value: initiationNonce, + expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, + stage: "initiation", + reconnect: true, }); } @@ -298,16 +307,23 @@ export class UserAccount extends DurableObject { } const oauthNonce = generateNonce(); this.ctx.storage.kv.put("nonce", { - value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + value: oauthNonce, + expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, + stage: "oauth", + reconnect: stored.reconnect, }); return { oauthNonce }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -319,22 +335,34 @@ export class UserAccount extends DurableObject { const grant = await exchangeAuthCode( code, this.env.CLIENT_ID, this.env.CLIENT_SECRET, getBaseUrl(this.env) + "/oauth"); - this.#storeGrant(grant); - await this.#refreshSitesAndIdentity(grant.accessToken); - if (this.ctx.storage.kv.get("reconnecting")) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(new Date(grant.expiresAt)); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId, new Date(grant.expiresAt)); } else { + this.#storeGrant(grant); + await this.#refreshSitesAndIdentity(grant.accessToken); try { const props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props }), new Date(grant.expiresAt)); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props }), new Date(grant.expiresAt)); } catch (err) { this.ctx.storage.kv.delete("grant"); throw err; } } - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#storeGrant(grant); + await this.#refreshSitesAndIdentity(grant.accessToken); } #storeGrant(grant: StoredGrant) { @@ -500,6 +528,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#userAccount().commitReconnect(stageId); + } + @skipRpcValidation() async getVerifier(): Promise> { const props: ConfluenceVerifierProps = { userObjectId: this.ctx.props.userObjectId }; diff --git a/packages/gatekeeper-context/src/library-gatekeeper.ts b/packages/gatekeeper-context/src/library-gatekeeper.ts index b5dbeac80c..03a1c784c6 100644 --- a/packages/gatekeeper-context/src/library-gatekeeper.ts +++ b/packages/gatekeeper-context/src/library-gatekeeper.ts @@ -183,6 +183,9 @@ export class ContextAccount reconnect(): never { throw new Error("The Context Library is a singleton gatekeeper; it has no connect flow."); } + commitReconnect(_stageId: string): never { + throw new Error("The Context Library is a singleton gatekeeper; it has no connect flow."); + } async getAuthenticatedEmail(): Promise { return null; } diff --git a/packages/gatekeeper-email/package.json b/packages/gatekeeper-email/package.json index 88804c0963..611e5712bc 100644 --- a/packages/gatekeeper-email/package.json +++ b/packages/gatekeeper-email/package.json @@ -11,6 +11,7 @@ "dependencies": { "@gadgets/backend-utils": "workspace:*", "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:", diff --git a/packages/gatekeeper-email/src/email.ts b/packages/gatekeeper-email/src/email.ts index 105b24d62a..8e3143a0d7 100644 --- a/packages/gatekeeper-email/src/email.ts +++ b/packages/gatekeeper-email/src/email.ts @@ -16,7 +16,9 @@ import { SupportedResource, ResourceConfiguratorFrame, stripTrailingSlashes, + type ConnectHandoff, } from '@gadgets/workshop-shared/gatekeeper'; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; import { EmailSession, EmailHook, @@ -131,14 +133,6 @@ class EmailMailboxConfiguratorUI extends RpcTarget implements EmailMailboxConfig // ======================================================================================= -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS. - -`; - const INVALID_LINK_HTML = ` @@ -172,16 +166,13 @@ export default { // This is a connectAccount completion URL. Route to the UserAccount DO. let userObjectId = ctx.exports.UserAccount.idFromString(path[0]); let stub: DurableObjectStub = ctx.exports.UserAccount.get(userObjectId); - if (!await stub.complete(path[1])) { + let handoff = await stub.complete(path[1]); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { - headers: { - "Content-Type": "text/html; charset=utf-8" - } - }); + return htmlResponse(connectHandoffPageHtml(handoff)); } else { return new Response("Not Found", { status: 404 }); } @@ -307,29 +298,32 @@ export class UserAccount extends DurableObject { this.ctx.storage.kv.put("nonce", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS }); } - /** Returns false if the nonce is invalid or expired. */ - async complete(nonce: string): Promise { + /** + * Returns the handoff for the page the browser lands on, or null if the nonce is invalid or + * expired. + */ + async complete(nonce: string): Promise { let stored = this.ctx.storage.kv.get<{value: string, expiresAt: number}>("nonce"); if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); let callback = this.ctx.storage.kv.get>("callback"); if (!callback) { - return false; + return null; } let props: GatekeeperUserImplProps = { userAccountId: this.ctx.id.toString(), }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + let handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); // Clean up the callback, but keep the DO alive to track claimed email addresses. this.ctx.storage.deleteAlarm(); this.ctx.storage.kv.delete("callback"); - return true; + return handoff; } async alarm(alarmInfo?: AlarmInvocationInfo): Promise { @@ -453,6 +447,11 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + // reconnect() never starts a flow, so nothing can ever be staged. + throw new Error("No reconnect is awaiting confirmation. Please try again."); + } + async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> { return {}; } diff --git a/packages/gatekeeper-github/__tests__/github-api.test.ts b/packages/gatekeeper-github/__tests__/github-api.test.ts index 5d03258f34..07445ecd22 100644 --- a/packages/gatekeeper-github/__tests__/github-api.test.ts +++ b/packages/gatekeeper-github/__tests__/github-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GitHubApi, + revokeOAuthToken, type GitHubIssueResponse, } from "../src/github-api"; import { @@ -199,3 +200,25 @@ describe("GitHubApi git reads", () => { expect(Object.fromEntries(url().searchParams)).toEqual({ page: "3", per_page: "50" }); }); }); + +describe("revokeOAuthToken", () => { + it("revokes only the given token, never the whole grant", async () => { + // `/applications/{id}/grant` would revoke every token the user holds for the app, taking a + // working connection down with the duplicate or abandoned one being dropped. + let requestUrl: URL | undefined; + let init: RequestInit | undefined; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, options?: RequestInit) => { + requestUrl = new URL(String(input)); + init = options; + return new Response(null, { status: 204 }); + })); + + await revokeOAuthToken("gho_token", "client/id", "client-secret"); + + expect(init?.method).toBe("DELETE"); + expect(requestUrl?.pathname).toBe("/applications/client%2Fid/token"); + expect(JSON.parse(String(init?.body))).toEqual({ access_token: "gho_token" }); + expect(new Headers(init?.headers).get("Authorization")) + .toBe(`Basic ${btoa("client/id:client-secret")}`); + }); +}); diff --git a/packages/gatekeeper-github/package.json b/packages/gatekeeper-github/package.json index a55e654bbb..1d1bd2f026 100644 --- a/packages/gatekeeper-github/package.json +++ b/packages/gatekeeper-github/package.json @@ -12,6 +12,7 @@ "dependencies": { "@gadgets/backend-utils": "workspace:*", "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:", diff --git a/packages/gatekeeper-github/src/github-api.ts b/packages/gatekeeper-github/src/github-api.ts index 8008bb3e18..da64df7037 100644 --- a/packages/gatekeeper-github/src/github-api.ts +++ b/packages/gatekeeper-github/src/github-api.ts @@ -413,14 +413,21 @@ export async function exchangeAuthCode( }; } -export async function revokeOAuthGrant( +/** + * Revokes one OAuth token, and only that token. The neighbouring `/applications/{id}/grant` + * endpoint revokes every token the user holds for this OAuth app at once, which took a working + * connection down whenever a duplicate or an abandoned pending connect for the same user was + * revoked; a user may legitimately hold several tokens (one per connected account, plus the + * transient sign-in grant). + */ +export async function revokeOAuthToken( accessToken: string, clientId: string, clientSecret: string, ): Promise { await request( "DELETE", - `/applications/${encodeURIComponent(clientId)}/grant`, + `/applications/${encodeURIComponent(clientId)}/token`, { auth: "basic", basicAuth: { diff --git a/packages/gatekeeper-github/src/github.ts b/packages/gatekeeper-github/src/github.ts index 0d4e89814e..88560eb586 100644 --- a/packages/gatekeeper-github/src/github.ts +++ b/packages/gatekeeper-github/src/github.ts @@ -5,6 +5,7 @@ import { stripTrailingSlashes, type ActionDescription, type AccountDescription, + type ConnectHandoff, type Cursor, type Gatekeeper, type GatekeeperConnectCallback, @@ -20,11 +21,13 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { GitHubApi, GitHubApiError, exchangeAuthCode, - revokeOAuthGrant, + revokeOAuthToken, type ConditionalRequestResult, type GitHubCompareResponse, type GitHubIssueCommentResponse, @@ -131,6 +134,12 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; type ResourceKind = "repo" | "issue" | "pull"; @@ -384,14 +393,6 @@ const SUPPORTED_RESOURCES: SupportedResource[] = [ PULL_REQUEST_RESOURCE, ]; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` @@ -1183,16 +1184,14 @@ export default { const stub: DurableObjectStub = ctx.exports.UserAccount.get( ctx.exports.UserAccount.idFromString(doId), ); - const accepted = await stub.acceptAuthCode(code, oauthNonce); - if (!accepted) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - return new Response(SELF_CLOSING_HTML, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); @@ -1258,12 +1257,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -1278,15 +1277,20 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); const scopes = this.ctx.storage.kv.get("requestedScopes") ?? OAUTH_SCOPES; return { oauthNonce, scopes }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -1304,34 +1308,47 @@ export class UserAccount extends DurableObject { const grant = await exchangeAuthCode(code, clientId, clientSecret, `${getBaseUrl(this.env)}/oauth`); - this.ctx.storage.kv.put("accessToken", grant.accessToken); - this.ctx.storage.kv.put("scopes", grant.scopes); - this.ctx.storage.kv.put("expiredNotified", false); - - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. The stage id ties the Workshop's ticket to + // this grant, so an overlapping reconnect cannot be committed by it. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.ctx.storage.kv.put("accessToken", grant.accessToken); + this.ctx.storage.kv.put("scopes", grant.scopes); + this.ctx.storage.kv.put("expiredNotified", false); try { const props = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (error) { this.ctx.storage.kv.delete("accessToken"); this.ctx.storage.kv.delete("scopes"); throw error; } // Auth-only sign-in grants are transient: the caller read the email via complete(), so - // schedule a prompt self-destruct. We do NOT call the provider revoke endpoint (it could - // invalidate the user's other grants for this OAuth app); we just drop our local copy. + // schedule a prompt self-destruct. Only the local copy is dropped, with no provider revoke + // call: the token grants nothing worth revoking, and this is the sign-in path. if (this.ctx.storage.kv.get("ephemeral")) { await this.ctx.storage.setAlarm(Date.now() + 2 * 60 * 1000); - return true; + return handoff; } } await this.ctx.storage.deleteAlarm(); - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials>>( + this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.ctx.storage.kv.put("accessToken", grant.accessToken); + this.ctx.storage.kv.put("scopes", grant.scopes); + this.ctx.storage.kv.put("expiredNotified", false); } getAccessToken(): string { @@ -1370,10 +1387,10 @@ export class UserAccount extends DurableObject { const accessToken = this.ctx.storage.kv.get("accessToken"); if (accessToken && this.env.CLIENT_ID && this.env.CLIENT_SECRET) { try { - await revokeOAuthGrant(accessToken, this.env.CLIENT_ID, this.env.CLIENT_SECRET); + await revokeOAuthToken(accessToken, this.env.CLIENT_ID, this.env.CLIENT_SECRET); } catch (error) { - logger.error("failed to revoke GitHub OAuth grant", { - event: "oauth.grant.revoke.failed", error, + logger.error("failed to revoke GitHub OAuth token", { + event: "oauth.token.revoke.failed", error, }); } } @@ -1511,6 +1528,11 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + const id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); + await this.ctx.exports.UserAccount.get(id).commitReconnect(stageId); + } + async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> { return {}; } diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 7167e3ab13..4b44683ed6 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -1,6 +1,8 @@ import { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers"; import { skipRpcValidation, validateRpc } from "capnweb-validate"; -import { GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor as GatekeeperVendorIface, Gatekeeper, ResourceDescription, ApprovalQueue, ObservationDescription, VendorDescription, GatekeeperConnectCallback, GatekeeperConnectOptions, AccountDescription, SupportedResource, ResourceConfiguratorFrame, Cursor, ActionKind, GitCache } from '@gadgets/workshop-shared/gatekeeper'; +import { GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor as GatekeeperVendorIface, Gatekeeper, ResourceDescription, ApprovalQueue, ObservationDescription, VendorDescription, GatekeeperConnectCallback, GatekeeperConnectOptions, AccountDescription, SupportedResource, ResourceConfiguratorFrame, Cursor, ActionKind, GitCache, type ConnectHandoff } from '@gadgets/workshop-shared/gatekeeper'; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { PreviewOAuth, PreviewOAuthConfigurationError, @@ -155,14 +157,6 @@ type Env = Cloudflare.Env & GoogleOAuthEnv & { // ======================================================================================= -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS. - -`; - const INVALID_LINK_HTML = ` @@ -299,16 +293,13 @@ export default { let code = url.searchParams.get("code"); if (!code) return new Response("Error: no 'code' provided", { status: 400 }); - if (!await stub.acceptAuthCode(code, oauthState.oauthNonce)) { + let handoff = await stub.acceptAuthCode(code, oauthState.oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { - headers: { - "Content-Type": "text/html; charset=utf-8" - } - }); + return htmlResponse(connectHandoffPageHtml(handoff)); } else { return new Response("Not Found", {status: 404}); } @@ -399,6 +390,14 @@ class Mutex { } } +/** What a reconnect flow obtains, held in escrow until commitReconnect() writes it live. */ +type StagedGoogleCredentials = { + refreshToken: string; + accessToken: GoogleAccessToken; + grantedScopes: string[]; + requestedResources: string[]; +}; + export class UserAccount extends DurableObject { // Serialize minting, reconnect, and revoke against each other. Minting is a network round trip, so // without this a single invalidated token has every concurrent caller mint its own — a burst @@ -465,10 +464,13 @@ export class UserAccount extends DurableObject { consumeOAuthNonce(oauthNonce: string): boolean { return claimStoredOAuthFlow(this.ctx.storage.kv, oauthNonce, Date.now()) !== null; } - /** Returns false if the OAuth nonce is invalid or expired. */ - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null if the OAuth nonce is invalid or expired. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { let flow = claimStoredOAuthFlow(this.ctx.storage.kv, oauthNonce, Date.now()); - if (!flow) return false; + if (!flow) return null; let { CLIENT_ID: clientId, CLIENT_SECRET: clientSecret } = this.env; if (!clientId || !clientSecret) { @@ -494,22 +496,35 @@ export class UserAccount extends DurableObject { throw new Error("OAuth exchange didn't return refresh token?"); } + if (flow.mode === "reconnect") { + // The reconnect URL is a bearer capability, so the new grant is only staged until the + // Workshop has confirmed the browser that finished the flow is the owner's (see + // commitReconnect). Bound gadgets keep reading the current token meanwhile. + let staged: StagedGoogleCredentials = { + refreshToken: response.refreshToken, accessToken: response.accessToken, + grantedScopes: response.grantedScopes, requestedResources: flow.requestedResources, + }; + let stageId = stageCredentials(this.ctx.storage.kv, staged, Date.now()); + return { callback, mode: flow.mode, stageId }; + } + this.ctx.storage.kv.put("refreshToken", response.refreshToken); this.ctx.storage.kv.put("accessToken", response.accessToken); // These credentials are new, so any recorded permanent failure no longer applies this.#mintFailure = undefined; this.ctx.storage.kv.put("grantedScopes", response.grantedScopes); mergeGrantedResources(this.ctx.storage.kv, flow.requestedResources); - return { callback, mode: flow.mode }; + return { callback, mode: flow.mode, stageId: undefined }; }); let callback = completion.callback; - if (completion.mode === "reconnect") { - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (completion.stageId !== undefined) { + handoff = await callback.reconnectComplete(completion.stageId); } else { try { let props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({props})); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({props})); } catch (err) { this.ctx.storage.kv.delete("refreshToken"); throw err; @@ -523,7 +538,22 @@ export class UserAccount extends DurableObject { } } - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + await this.#credentials.run(async () => { + let staged = commitStagedCredentials( + this.ctx.storage.kv, Date.now(), stageId); + if (!staged) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.ctx.storage.kv.put("refreshToken", staged.refreshToken); + this.ctx.storage.kv.put("accessToken", staged.accessToken); + // These credentials are new, so any recorded permanent failure no longer applies + this.#mintFailure = undefined; + this.ctx.storage.kv.put("grantedScopes", staged.grantedScopes); + mergeGrantedResources(this.ctx.storage.kv, staged.requestedResources); + }); } /** @@ -838,6 +868,11 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); + await this.ctx.exports.UserAccount.get(id).commitReconnect(stageId); + } + async ensureResources(resourceUrlPatterns: string[]): Promise<{url?: string}> { let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); let obj = this.ctx.exports.UserAccount.get(id); diff --git a/packages/gatekeeper-google/tsconfig.json b/packages/gatekeeper-google/tsconfig.json index 7ddb714ddc..032309c772 100644 --- a/packages/gatekeeper-google/tsconfig.json +++ b/packages/gatekeeper-google/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "target": "ES2022", - "lib": ["ES2023", "ESNext.Disposable"], + "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react", diff --git a/packages/gatekeeper-homeassistant/package.json b/packages/gatekeeper-homeassistant/package.json index 28b0978aa4..a5bf2a82b3 100644 --- a/packages/gatekeeper-homeassistant/package.json +++ b/packages/gatekeeper-homeassistant/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-homeassistant/src/homeassistant.ts b/packages/gatekeeper-homeassistant/src/homeassistant.ts index 7c131d7227..f9317691ab 100644 --- a/packages/gatekeeper-homeassistant/src/homeassistant.ts +++ b/packages/gatekeeper-homeassistant/src/homeassistant.ts @@ -5,6 +5,7 @@ import { stripTrailingSlashes, type AccountDescription, type AvatarImage, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperUser, @@ -15,6 +16,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import INSTANCE_CONFIGURATOR_HTML from "./generated/instance-configurator-ui.txt"; import AREA_CONFIGURATOR_HTML from "./generated/area-configurator-ui.txt"; import LABEL_CONFIGURATOR_HTML from "./generated/label-configurator-ui.txt"; @@ -237,16 +240,6 @@ const CONNECT_FORM_HTML = (params: { actionUrl: string; error?: string }) => ` `; -const SELF_CLOSING_HTML = ` - -Connected - - -

Connected!

-

Home Assistant has been linked to Cloudflare OS. You may close this tab.

- -`; - const INVALID_LINK_HTML = ` Link Expired @@ -339,9 +332,7 @@ export default { { headers: { "Content-Type": "text/html; charset=utf-8" }, status: 400 }, ); } - return new Response(SELF_CLOSING_HTML, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return htmlResponse(connectHandoffPageHtml(result.handoff)); } } @@ -395,10 +386,21 @@ interface StoredCredentials { interface StoredNonce { value: string; expiresAt: number; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; + /** + * Set while a submission is being validated against Home Assistant, so a concurrent submission + * cannot pass the same nonce; cleared again when validation fails so the user can resubmit. + */ + connecting?: true; } type CompleteConnectionResult = - | { kind: "ok" } + | { kind: "ok"; handoff: ConnectHandoff } | { kind: "invalid_nonce" } | { kind: "error"; message: string }; @@ -415,15 +417,18 @@ export class UserAccount extends DurableObject { } async prepareReconnect(nonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS, + reconnect: true, }); } - /** Validates the nonce but does not consume it (so the user can resubmit if validation fails). */ + /** + * Validates the nonce without claiming it, for the GET preview of the form. A submission that + * fails validation releases its claim (see completeConnection), so the user can resubmit. + */ async verifyNonceWithoutConsuming(nonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || Date.now() >= stored.expiresAt) return false; @@ -436,9 +441,14 @@ export class UserAccount extends DurableObject { token: string, ): Promise { const stored = this.ctx.storage.kv.get("nonce"); - if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) { + if (!stored || stored.connecting || Date.now() >= stored.expiresAt + || !constantTimeEqual(stored.value, nonce)) { return { kind: "invalid_nonce" }; } + // Claim the nonce before the first await. The Durable Object's input gate does not cover the + // outbound ping, so a second submission arriving meanwhile would otherwise validate the same + // nonce, complete the connection a second time, and later revoke the account this one activated. + this.ctx.storage.kv.put("nonce", { ...stored, connecting: true }); // Validate that the URL+token can actually talk to HA. const creds: HomeAssistantCredentials = { baseUrl, token }; @@ -446,6 +456,7 @@ export class UserAccount extends DurableObject { const rest = new HomeAssistantRest(creds); await rest.ping(); } catch (e: any) { + this.#releaseNonceClaim(nonce); const msg = e instanceof HomeAssistantError ? e.message : `Unable to reach Home Assistant: ${e?.message ?? e}`; @@ -455,28 +466,30 @@ export class UserAccount extends DurableObject { // Consume the nonce now that we've validated. this.ctx.storage.kv.delete("nonce"); - this.ctx.storage.kv.put("credentials", { baseUrl, token }); - this.ctx.storage.kv.put("expiredNotified", false); - const callback = this.ctx.storage.kv.get>("callback"); if (!callback) { // Callback evicted — should not normally happen. - this.ctx.storage.kv.delete("credentials"); return { kind: "error", message: "Connection callback expired. Please restart." }; } - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new credentials are only staged until the + // Workshop has confirmed the browser that finished the flow is the owner's (see + // commitReconnect). Bound gadgets keep reading the current token meanwhile. + const stageId = stageCredentials( + this.ctx.storage.kv, { baseUrl, token }, Date.now()); try { - await callback.credentialsRestored(); + handoff = await callback.reconnectComplete(stageId); } catch (e: any) { return { kind: "error", message: `Failed to notify workshop: ${e?.message ?? e}` }; } } else { + this.ctx.storage.kv.put("credentials", { baseUrl, token }); + this.ctx.storage.kv.put("expiredNotified", false); try { const props: HomeAssistantUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.HomeAssistantUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.HomeAssistantUserImpl({ props })); } catch (e: any) { this.ctx.storage.kv.delete("credentials"); return { kind: "error", message: `Failed to notify workshop: ${e?.message ?? e}` }; @@ -484,7 +497,24 @@ export class UserAccount extends DurableObject { } await this.ctx.storage.deleteAlarm(); - return { kind: "ok" }; + return { kind: "ok", handoff }; + } + + // Releases a failed submission's claim without reopening a nonce that another flow replaced while + // this request was suspended. Synchronous storage makes the check and put one step. + #releaseNonceClaim(nonce: string): void { + const stored = this.ctx.storage.kv.get("nonce"); + if (!stored || !stored.connecting || !constantTimeEqual(stored.value, nonce)) return; + const { connecting: _, ...released } = stored; + this.ctx.storage.kv.put("nonce", released); + } + + /** Makes the credentials staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const creds = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!creds) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.ctx.storage.kv.put("credentials", creds); + this.ctx.storage.kv.put("expiredNotified", false); } getCredentials(): HomeAssistantCredentials { @@ -682,6 +712,10 @@ export class HomeAssistantUserImpl return { url: `${getBaseUrl(this.env)}/${this.ctx.props.userObjectId}/${nonce}` }; } + async commitReconnect(stageId: string): Promise { + await this.#userAccount().commitReconnect(stageId); + } + async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> { return {}; } diff --git a/packages/gatekeeper-kit/__tests__/connect-pages.test.ts b/packages/gatekeeper-kit/__tests__/connect-pages.test.ts index e4db9de099..6c768017ad 100644 --- a/packages/gatekeeper-kit/__tests__/connect-pages.test.ts +++ b/packages/gatekeeper-kit/__tests__/connect-pages.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, +} from "@gadgets/workshop-shared/gatekeeper"; +import { + connectHandoffPageHtml, connectMutationError, errorPageHtml, escapeHtml, htmlResponse, INVALID_LINK_HTML, - SELF_CLOSING_HTML, } from "../src/connect-pages"; +const HANDOFF = { targetOrigin: "https://workshop.example", ticket: "a".repeat(64) }; + describe("connect pages", () => { it("escapes every character that could break out of markup", () => { expect(escapeHtml(`&`)) @@ -23,7 +28,9 @@ describe("connect pages", () => { }); it("declares a language and viewport on every page it serves", () => { - for (const html of [SELF_CLOSING_HTML, INVALID_LINK_HTML, errorPageHtml("Failed", "Retry")]) { + for (const html of [ + connectHandoffPageHtml(HANDOFF), INVALID_LINK_HTML, errorPageHtml("Failed", "Retry"), + ]) { expect(html).toContain(``); expect(html).toContain(`name="viewport"`); } @@ -42,6 +49,96 @@ describe("connect pages", () => { }); }); +describe("connectHandoffPageHtml", () => { + // Pulls the envelope and target origin the page's script posts out of its two literals. + function postMessageArgs(html: string): [unknown, string] { + const envelope = /var envelope = (.*);\n/.exec(html); + const target = /var target = (".*?");\n/.exec(html); + expect(envelope).not.toBeNull(); + expect(target).not.toBeNull(); + // The literals are JSON with `<`, `>` and `&` written as \uXXXX escapes, which JSON accepts. + return [JSON.parse(envelope![1]), JSON.parse(target![1])]; + } + + it("posts the versioned envelope to exactly the Workshop origin", () => { + const html = connectHandoffPageHtml(HANDOFF); + const [envelope, target] = postMessageArgs(html); + + expect(envelope).toEqual({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: HANDOFF.ticket }); + expect(target).toBe("https://workshop.example"); + expect(html).toContain("opener.postMessage(envelope, target)"); + }); + + it("falls back to a same-origin BroadcastChannel named after the message type", () => { + // A disowned connect popup has no opener; only when the page is on the Workshop's own origin + // may it broadcast, and the channel name is the versioned message type so the listener and the + // page cannot drift apart. + const html = connectHandoffPageHtml(HANDOFF); + + expect(html).toContain( + `else if (window.location.origin === target && "BroadcastChannel" in window)`); + expect(html).toContain( + `var channel = new BroadcastChannel(${JSON.stringify(CONNECT_HANDOFF_MESSAGE_TYPE)});`); + expect(html).toContain("channel.postMessage(envelope);"); + // The opener wins when there is one: sign-in and the dev server rely on it. + expect(html.indexOf("opener.postMessage")).toBeLessThan(html.indexOf("new BroadcastChannel")); + }); + + it("repeats a broadcast until the Workshop acknowledges this ticket, then closes", () => { + // A Workshop tab whose session is mid-reconnect misses a one-shot broadcast, and the connect + // would fail silently. The ticket is single-use server-side, so repeating it is safe; the ack + // for this ticket is what ends the repeats. + const html = connectHandoffPageHtml(HANDOFF); + + expect(html).toContain("setInterval(function () { channel.postMessage(envelope); }, 1000)"); + expect(html).toContain(`e.data.type === ${JSON.stringify(CONNECT_HANDOFF_ACK_MESSAGE_TYPE)}`); + expect(html).toContain("e.data.ticket === envelope.ticket"); + // Gives up after 30 s with the "couldn't reach" text rather than closing on a timer: the + // channel branch returns before the 2 s fallback close, which is for the opener branch only. + expect(html).toContain("setTimeout(function () { clearInterval(repeat); unreachable(); }, 30000)"); + const channelBranch = html.slice(html.indexOf("var channel"), html.indexOf("} else {")); + expect(channelBranch).toContain("return;"); + expect(channelBranch).not.toContain("2000"); + }); + + it("cannot be broken out of by the ticket or origin it embeds", () => { + const hostile = { targetOrigin: "https://workshop.example", ticket: `&'"` }; + const html = connectHandoffPageHtml(hostile); + + expect(html).not.toContain("")).toHaveLength(2); + expect(html.split("")).toHaveLength(2); + expect(postMessageArgs(html)[0]).toEqual({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: hostile.ticket }); + }); + + it("refuses a targetOrigin that is not exactly an origin", () => { + // A path or trailing slash would make the browser drop the message; an unparsable value or an + // opaque origin would be far worse — `postMessage(…, "*")` style delivery to anyone. + for (const targetOrigin of [ + "https://workshop.example/", "https://workshop.example/app", "*", "null", "workshop.example", + "", "javascript:alert(1)", + ]) { + expect(() => connectHandoffPageHtml({ ...HANDOFF, targetOrigin })) + .toThrow("targetOrigin is not an origin"); + } + expect(() => connectHandoffPageHtml({ ...HANDOFF, targetOrigin: "http://localhost:3000" })) + .not.toThrow(); + }); + + it("tells the user when it can reach no Workshop, and only closes when it could", () => { + const html = connectHandoffPageHtml(HANDOFF); + + expect(html).toContain("if (opener && !opener.closed)"); + expect(html).toContain("setTimeout(function () { window.close(); }, 2000)"); + // The "couldn't reach" branch returns before the close timer, so the message stays readable. + expect(html.lastIndexOf("return;")).toBeLessThan( + html.indexOf("setTimeout(function () { window.close(); }, 2000)")); + expect(html).toContain("couldn't reach the Workshop"); + expect(html).toContain("start the connection again"); + expect(html).toContain(``); + }); +}); + describe("connectMutationError", () => { const origin = "https://gatekeeper.example"; const json = { origin, contentType: "application/json" }; diff --git a/packages/gatekeeper-kit/__tests__/credential-stage.test.ts b/packages/gatekeeper-kit/__tests__/credential-stage.test.ts new file mode 100644 index 0000000000..6b71150841 --- /dev/null +++ b/packages/gatekeeper-kit/__tests__/credential-stage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + commitStagedCredentials, + discardStagedCredentials, + peekStagedCredentials, + stageCredentials, + STAGED_CREDENTIALS_KEY, +} from "../src/credential-stage"; +import { OAUTH_NONCE_LIFETIME_MS } from "../src/connect-nonce"; +import { fakeKv } from "./fake-kv"; + +type Grant = { accessToken: string; scopes: string[] }; +const GRANT: Grant = { accessToken: "new-token", scopes: ["repo"] }; + +describe("credential stage", () => { + it("pins the durable key", () => { + expect(STAGED_CREDENTIALS_KEY).toBe("stagedCredentials"); + }); + + it("commits what was staged exactly once, to the id it handed out", () => { + const kv = fakeKv(); + const stageId = stageCredentials(kv, GRANT, 1_000); + + expect(stageId).toMatch(/^[0-9a-f]{64}$/); + expect(kv.keys()).toEqual([STAGED_CREDENTIALS_KEY]); + expect(commitStagedCredentials(kv, 2_000, stageId)).toEqual(GRANT); + expect(kv.keys()).toEqual([]); + expect(commitStagedCredentials(kv, 2_000, stageId)).toBeNull(); + }); + + it("refuses a ticket from an earlier stage and leaves the newer stage for its own", () => { + // Two reconnects overlapped: the owner's (or an attacker's) finished first, then a phished + // victim's replaced the stage. The first flow's ticket must not activate the second's tokens. + const kv = fakeKv(); + const first = stageCredentials(kv, { ...GRANT, accessToken: "first" }, 1_000); + const second = stageCredentials(kv, { ...GRANT, accessToken: "second" }, 1_200); + expect(first).not.toBe(second); + + expect(commitStagedCredentials(kv, 1_500, first)).toBeNull(); + expect(kv.keys()).toEqual([STAGED_CREDENTIALS_KEY]); + expect(peekStagedCredentials(kv, 1_500)).toEqual({ + creds: { ...GRANT, accessToken: "second" }, stageId: second, + }); + expect(commitStagedCredentials(kv, 1_500, second)).toEqual({ ...GRANT, accessToken: "second" }); + expect(kv.keys()).toEqual([]); + }); + + it("discards an expired stage rather than committing it", () => { + const kv = fakeKv(); + const stageId = stageCredentials(kv, GRANT, 1_000); + + expect(commitStagedCredentials(kv, 1_000 + OAUTH_NONCE_LIFETIME_MS, stageId)).toBeNull(); + expect(kv.keys()).toEqual([]); + }); + + it("honours a caller-chosen lifetime", () => { + const kv = fakeKv(); + const stageId = stageCredentials(kv, GRANT, 1_200, 500); + + expect(peekStagedCredentials(kv, 1_600)?.creds).toEqual(GRANT); + expect(peekStagedCredentials(kv, 1_700)).toBeNull(); + expect(commitStagedCredentials(kv, 1_600, stageId)).toEqual(GRANT); + }); + + it("peeks without consuming and fails closed on a corrupt or unusable clock", () => { + const kv = fakeKv(); + expect(peekStagedCredentials(kv, 1_000)).toBeNull(); + const stageId = stageCredentials(kv, GRANT, 1_000); + + expect(peekStagedCredentials(kv, 1_500)).toEqual({ creds: GRANT, stageId }); + expect(peekStagedCredentials(kv, 1_500)).toEqual({ creds: GRANT, stageId }); + expect(peekStagedCredentials(kv, Number.NaN)).toBeNull(); + expect(kv.keys()).toEqual([STAGED_CREDENTIALS_KEY]); + + kv.put(STAGED_CREDENTIALS_KEY, { creds: GRANT, stageId, expiresAt: "soon" }); + expect(commitStagedCredentials(kv, 1_000, stageId)).toBeNull(); + expect(kv.keys()).toEqual([]); + + // A record written before stages carried an id is unusable, not committable by anyone. + kv.put(STAGED_CREDENTIALS_KEY, { creds: GRANT, expiresAt: 5_000 }); + expect(peekStagedCredentials(kv, 1_000)).toBeNull(); + expect(commitStagedCredentials(kv, 1_000, "")).toBeNull(); + expect(kv.keys()).toEqual([]); + }); + + it("discards the stage on request, touching nothing else", () => { + const kv = fakeKv(); + kv.put("tokens", { access_token: "live" }); + stageCredentials(kv, GRANT, 1_000); + + discardStagedCredentials(kv); + expect(kv.keys()).toEqual(["tokens"]); + discardStagedCredentials(kv); + expect(kv.keys()).toEqual(["tokens"]); + }); +}); diff --git a/packages/gatekeeper-kit/package.json b/packages/gatekeeper-kit/package.json index 2368f7d3a0..767db8b79c 100644 --- a/packages/gatekeeper-kit/package.json +++ b/packages/gatekeeper-kit/package.json @@ -13,6 +13,7 @@ "./connect-nonce": "./src/connect-nonce.ts", "./connect-pages": "./src/connect-pages.ts", "./credential-expiry": "./src/credential-expiry.ts", + "./credential-stage": "./src/credential-stage.ts", "./credentials": "./src/credentials.ts", "./cursors": "./src/cursors.ts", "./endpoint": "./src/endpoint.ts", diff --git a/packages/gatekeeper-kit/src/connect-pages.ts b/packages/gatekeeper-kit/src/connect-pages.ts index 81c2f0b87d..723dd59174 100644 --- a/packages/gatekeeper-kit/src/connect-pages.ts +++ b/packages/gatekeeper-kit/src/connect-pages.ts @@ -1,5 +1,11 @@ /** Hardened HTML and browser request guards for gatekeeper connect flows. */ +import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, + CONNECT_HANDOFF_MESSAGE_TYPE, + type ConnectHandoff, +} from "@gadgets/workshop-shared/gatekeeper"; + const HTML_ESCAPES: Readonly> = { "&": "&", "<": "<", @@ -119,11 +125,104 @@ export const PAGE_STYLE = ` p.err { color: var(--danger); font-size: 13px; margin: 0 0 16px; } `; -/** The page a popup-based connect flow lands on: reports success and closes its own tab. */ -export const SELF_CLOSING_HTML = ` +/** + * Serializes a value for a `` — can end the script early; the two line terminators JSON + * allows but JavaScript did not are escaped for older parsers. + * @param value JSON-serializable value. + * @returns A JavaScript expression evaluating to the value. + */ +function scriptLiteral(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, char => + `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`); +} + +/** + * The page a connect flow lands on when it has finished. It delivers the handoff ticket to the + * Workshop and closes itself, over one of two transports: + * + * - `postMessage` to the window that opened it — and *only* to `handoff.targetOrigin`, the + * Workshop's origin, so a browser drops the message if the opener is anyone else. This is the + * sign-in path (the login page keeps the popup handle) and the dev-server path, where the Workshop + * is on another origin. + * - A `BroadcastChannel` named `CONNECT_HANDOFF_MESSAGE_TYPE`, when this page is itself on the + * Workshop's origin and has no opener. The Workshop disowns connect popups before navigating them + * (so no provider page ever holds a handle to the Workshop window), and a same-origin channel is + * the only thing a disowned popup can still reach; the browser scopes it to that origin. The + * envelope is repeated every second until a Workshop tab answers with a + * `CONNECT_HANDOFF_ACK_MESSAGE_TYPE` envelope for this ticket (a tab whose session is + * mid-reconnect would miss a one-shot broadcast, and the connect would fail silently); the ticket + * is single-use server-side, so the repeats are harmless. After 30 seconds unacknowledged the + * page gives up and tells the user, as below. + * + * Without either it can reach no Workshop, so it tells the user to go back and start again; a flow + * opened from a phished link on another origin ends here with its ticket unredeemed. The connection + * itself is inert until the Workshop redeems the ticket on the initiating user's session (see + * `GatekeeperVendor.connectAccount`). + * @param handoff The handoff returned by `GatekeeperConnectCallback.complete()` / + * `reconnectComplete()`. Its `targetOrigin` must be exactly an origin. + * @returns Escaped HTML; serve it with `htmlResponse()`. + * + * @example + * ```ts + * const handoff = await callback.complete(account); + * return htmlResponse(connectHandoffPageHtml(handoff)); + * ``` + */ +export function connectHandoffPageHtml(handoff: ConnectHandoff): string { + let origin: string; + try { + origin = new URL(handoff.targetOrigin).origin; + } catch { + origin = ""; + } + if (origin === "" || origin === "null" || origin !== handoff.targetOrigin) { + throw new Error("The connect handoff's targetOrigin is not an origin."); + } + const envelope = { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: handoff.ticket }; + return ` -Connected -

Connected. You can close this window.

`; + + +Connected +

Connected

+

Returning to the Workshop…

+`; +} /** The page a connect link that has expired or been used already lands on. */ export const INVALID_LINK_HTML = diff --git a/packages/gatekeeper-kit/src/credential-stage.ts b/packages/gatekeeper-kit/src/credential-stage.ts new file mode 100644 index 0000000000..d070b4f927 --- /dev/null +++ b/packages/gatekeeper-kit/src/credential-stage.ts @@ -0,0 +1,117 @@ +/** + * Escrow for credentials a reconnect / ensureResources flow obtained but the Workshop has not yet + * confirmed came from the account's owner (see `GatekeeperUser.reconnect` in workshop-shared). + * + * A reconnect URL is a bearer capability, and gadgets bound to the account read its live credentials + * straight from the gatekeeper, so a flow that wrote its result live would hand those gadgets a + * phished victim's tokens with nothing in the way. Instead the flow stages them here and reports + * `reconnectComplete()`; only `GatekeeperUser.commitReconnect()`, called once the Workshop has + * verified the completing browser, moves them to the live keys. Nothing else reads this key: staged + * credentials are unusable until committed, and the next stage overwrites them. + * + * Every stage carries a random `stageId`, which the flow passes to `reconnectComplete()` so the + * Workshop's ticket names the exact credentials whose completion minted it. Two reconnects can + * overlap — the owner's and one a phished victim finished — and a commit that took "whatever is + * staged" would let the ticket from the first activate the second's credentials. + */ + +import { generateNonce, OAUTH_NONCE_LIFETIME_MS } from "./connect-nonce"; +import type { KvMutable } from "./kv"; + +/** KV key holding the staged credentials. */ +export const STAGED_CREDENTIALS_KEY = "stagedCredentials"; + +type StagedCredentials = { creds: T; stageId: string; expiresAt: number }; + +/** A stage's credentials together with the id a commit must name to take them. */ +export type StagedCredentialsView = { creds: T; stageId: string }; + +/** + * Stages credentials for a later `commitStagedCredentials`, replacing any earlier stage. + * @param kv Durable Object storage. + * @param creds Whatever the connector needs to write its live keys on commit. + * @param now Current Unix time in milliseconds. + * @param ttlMs How long the stage stays committable; the Workshop redeems well within the default. + * @returns The new stage's id, to pass to `GatekeeperConnectCallback.reconnectComplete()`. + * + * @example + * ```ts + * const stageId = stageCredentials(this.ctx.storage.kv, { accessToken, scopes }, Date.now()); + * return callback.reconnectComplete(stageId); + * ``` + */ +export function stageCredentials( + kv: KvMutable, + creds: T, + now: number, + ttlMs: number = OAUTH_NONCE_LIFETIME_MS, +): string { + const stageId = generateNonce(); + kv.put>(STAGED_CREDENTIALS_KEY, { creds, stageId, expiresAt: now + ttlMs }); + return stageId; +} + +// The stage as stored, or `undefined` when there is none or it is unusable (expired, corrupt, or +// read with a non-finite clock). +function liveStage(kv: KvMutable, now: number): StagedCredentials | undefined { + const staged = kv.get>(STAGED_CREDENTIALS_KEY); + if (staged === undefined || typeof staged.stageId !== "string" || + !Number.isFinite(staged.expiresAt) || !Number.isFinite(now) || now >= staged.expiresAt) { + return undefined; + } + return staged; +} + +/** + * Reads the staged credentials without consuming them, for a connector that needs the id of a stage + * it wrote earlier in the same flow, or must *use* the credentials once before commit. Every other + * reader waits for `commitStagedCredentials`. + * @param kv Durable Object storage. + * @param now Current Unix time in milliseconds. + * @returns The staged credentials and their stage id, or `null` when nothing live is staged. + */ +export function peekStagedCredentials(kv: KvMutable, now: number): StagedCredentialsView | null { + const staged = liveStage(kv, now); + return staged ? { creds: staged.creds, stageId: staged.stageId } : null; +} + +/** + * Takes the staged credentials, if the stage named by `stageId` is the current one and still live. + * A matching stage is deleted, so a commit happens at most once; an expired or unusable stage is + * deleted too rather than left for a later caller. A stage with a *different* id is left in place + * and `null` is returned: it belongs to a newer flow, whose own ticket is the only thing that may + * commit it. + * @param kv Durable Object storage. + * @param now Current Unix time in milliseconds. + * @param stageId The id `stageCredentials` returned for the stage being committed. + * @returns The staged credentials, or `null` when that stage is not live. + * + * @example + * ```ts + * const staged = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + * if (!staged) throw new Error("Nothing to commit."); + * this.ctx.storage.kv.put("accessToken", staged.accessToken); + * ``` + */ +export function commitStagedCredentials(kv: KvMutable, now: number, stageId: string): T | null { + const staged = liveStage(kv, now); + if (staged === undefined) { + kv.delete(STAGED_CREDENTIALS_KEY); + return null; + } + // Plain comparison: the id is an identity, not a secret. Only the Workshop can reach + // `commitReconnect`, and it names the id its own record carries. + if (staged.stageId !== stageId) return null; + kv.delete(STAGED_CREDENTIALS_KEY); + return staged.creds; +} + +/** + * Drops the stage, if any, leaving the live credentials alone. For a flow that is abandoning its + * reconnect — an OAuth client told to invalidate the tokens it holds while one is in progress + * should forget the staged ones, not the live ones. + * @param kv Durable Object storage. + */ +export function discardStagedCredentials(kv: KvMutable): void { + kv.delete(STAGED_CREDENTIALS_KEY); +} diff --git a/packages/gatekeeper-linear/package.json b/packages/gatekeeper-linear/package.json index 104c4ddb67..524020fd77 100644 --- a/packages/gatekeeper-linear/package.json +++ b/packages/gatekeeper-linear/package.json @@ -11,6 +11,7 @@ "dependencies": { "@gadgets/backend-utils": "workspace:*", "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-linear/src/linear.ts b/packages/gatekeeper-linear/src/linear.ts index 85d36b80eb..333d4fd409 100644 --- a/packages/gatekeeper-linear/src/linear.ts +++ b/packages/gatekeeper-linear/src/linear.ts @@ -13,9 +13,12 @@ import { GatekeeperConnectCallback, GatekeeperConnectOptions, AccountDescription, + ConnectHandoff, SupportedResource, ResourceConfiguratorFrame, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import type { Cursor, LinearWorkspace, @@ -122,14 +125,6 @@ const SUPPORTED_RESOURCES: SupportedResource[] = [WORKSPACE_RESOURCE, TEAM_RESOU const LINEAR_LOGO_URL = `data:image/svg+xml,${encodeURIComponent(LINEAR_LOGO_SVG)}`; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -461,12 +456,12 @@ export default { } const stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId)); - const accepted = await stub.acceptAuthCode(code, oauthNonce); - if (!accepted) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); @@ -513,7 +508,17 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe // --------------------------------------------------------------------------- // UserAccount DO — stores OAuth credentials and handles token refresh. -type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth" }; +type StoredNonce = { + value: string; + expiresAt: number; + stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; +}; export class UserAccount extends DurableObject { async setCallback(callback: Fetcher, initiationNonce: string): Promise { @@ -529,12 +534,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -549,15 +554,20 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); return { oauthNonce, scopes: OAUTH_SCOPES }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -577,17 +587,19 @@ export class UserAccount extends DurableObject { redirectUri: `${getBaseUrl(this.env)}/oauth`, }); - this.ctx.storage.kv.put("grant", grant); - this.ctx.storage.kv.put("expiredNotified", false); - - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.ctx.storage.kv.put("grant", grant); + this.ctx.storage.kv.put("expiredNotified", false); try { const props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (err) { this.ctx.storage.kv.delete("grant"); throw err; @@ -595,7 +607,15 @@ export class UserAccount extends DurableObject { } await this.ctx.storage.deleteAlarm(); - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.ctx.storage.kv.put("grant", grant); + this.ctx.storage.kv.put("expiredNotified", false); } /** Returns a currently-valid access token, refreshing it first if it is about to expire. */ @@ -794,6 +814,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#account().commitReconnect(stageId); + } + /** * Mint a verifier representing this account, used by LinearGatekeeperImpl.addObserver to confirm a * prospective observer may read a bound team/issue (and, for workspace bindings, the workspace and diff --git a/packages/gatekeeper-mcp-portal/src/portal.ts b/packages/gatekeeper-mcp-portal/src/portal.ts index a41def897a..854d8f430c 100644 --- a/packages/gatekeeper-mcp-portal/src/portal.ts +++ b/packages/gatekeeper-mcp-portal/src/portal.ts @@ -52,9 +52,9 @@ import { } from "@gadgets/mcp-shared/scope"; import { errorPageHtml, + connectHandoffPageHtml, htmlResponse, INVALID_LINK_HTML, - SELF_CLOSING_HTML, } from "@gadgets/mcp-shared/html"; import { handleMcpHttpRequest } from "@gadgets/mcp-shared/http"; import { @@ -264,7 +264,7 @@ async function continueConnect( if (outcome.kind === "invalid") return htmlResponse(INVALID_LINK_HTML, 400); if (outcome.kind === "redirect") return Response.redirect(outcome.url, 302); - return htmlResponse(SELF_CLOSING_HTML); + return htmlResponse(connectHandoffPageHtml(outcome.handoff)); } // --------------------------------------------------------------------------- diff --git a/packages/gatekeeper-mcp/src/mcp.ts b/packages/gatekeeper-mcp/src/mcp.ts index 3c8a26a8be..d94ba6dbe0 100644 --- a/packages/gatekeeper-mcp/src/mcp.ts +++ b/packages/gatekeeper-mcp/src/mcp.ts @@ -52,9 +52,9 @@ import { import { validateCustomEndpoint } from "@gadgets/mcp-shared/endpoint"; import { fetchOptions } from "@gadgets/mcp-shared/fetch"; import { + connectHandoffPageHtml, htmlResponse, INVALID_LINK_HTML, - SELF_CLOSING_HTML, } from "@gadgets/mcp-shared/html"; import { handleMcpHttpRequest } from "@gadgets/mcp-shared/http"; import { @@ -160,7 +160,7 @@ async function continueConnect( if (outcome.kind === "invalid") return htmlResponse(INVALID_LINK_HTML, 400); if (outcome.kind === "redirect") return Response.redirect(outcome.url, 302); - return htmlResponse(SELF_CLOSING_HTML); + return htmlResponse(connectHandoffPageHtml(outcome.handoff)); } // --------------------------------------------------------------------------- diff --git a/packages/gatekeeper-notion/package.json b/packages/gatekeeper-notion/package.json index 32caba8717..0090333737 100644 --- a/packages/gatekeeper-notion/package.json +++ b/packages/gatekeeper-notion/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-notion/src/notion.ts b/packages/gatekeeper-notion/src/notion.ts index af56af206e..c0ba5b3dd8 100644 --- a/packages/gatekeeper-notion/src/notion.ts +++ b/packages/gatekeeper-notion/src/notion.ts @@ -19,6 +19,7 @@ import { stripTrailingSlashes, type AccountDescription, type ApprovalQueue, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperUser, @@ -30,6 +31,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { NotionApi, NotionApiError, @@ -111,6 +114,12 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; type StoredAccountInfo = { @@ -164,14 +173,6 @@ const ITEM_RESOURCE: SupportedResource = { const SUPPORTED_RESOURCES: SupportedResource[] = [WORKSPACE_RESOURCE, ITEM_RESOURCE]; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -269,14 +270,13 @@ export default { if (!code) return new Response("Error: no 'code' provided"); const stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId)); - if (!await stub.acceptAuthCode(code, oauthNonce)) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - return new Response(SELF_CLOSING_HTML, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return htmlResponse(connectHandoffPageHtml(handoff)); } else { return new Response("Not Found", { status: 404 }); } @@ -335,15 +335,15 @@ export class UserAccount extends DurableObject { } /** - * Prepare this account for a reconnect: the next acceptAuthCode() replaces credentials and - * notifies via credentialsRestored() instead of complete(). + * Prepare this account for a reconnect: the next acceptAuthCode() stages the new credentials and + * notifies via reconnectComplete() instead of complete(). */ async prepareReconnect(initiationNonce: string) { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -359,16 +359,20 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); return { oauthNonce }; } - /** Exchange the auth code for tokens. Returns false if the OAuth nonce is invalid/expired. */ - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Exchange the auth code for tokens and return the handoff for the page the browser lands on, or + * null if the OAuth nonce is invalid/expired. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -384,23 +388,32 @@ export class UserAccount extends DurableObject { const grant = await exchangeAuthCode( code, this.env.CLIENT_ID, this.env.CLIENT_SECRET, getBaseUrl(this.env) + "/oauth"); - this.#storeGrant(grant); - - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.#storeGrant(grant); try { const props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (err) { this.ctx.storage.kv.delete("accessToken"); this.ctx.storage.kv.delete("refreshToken"); throw err; } } - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#storeGrant(grant); } #storeGrant(grant: NotionOAuthGrant) { @@ -565,6 +578,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#userAccount().commitReconnect(stageId); + } + /** * Mint a verifier representing this account, used by the Notion gatekeepers' addObserver to confirm * a prospective observer may read a bound page/database (and, for workspace bindings, the workspace diff --git a/packages/gatekeeper-scheduler/src/scheduler.ts b/packages/gatekeeper-scheduler/src/scheduler.ts index 06dac81673..acd9f987e5 100644 --- a/packages/gatekeeper-scheduler/src/scheduler.ts +++ b/packages/gatekeeper-scheduler/src/scheduler.ts @@ -374,6 +374,11 @@ export class ScheduleAccount throw new Error("Scheduled Tasks has no connect flow."); } + /** Rejects commit because no flow can ever stage credentials. */ + commitReconnect(_stageId: string): Promise { + throw new Error("Scheduled Tasks has no connect flow."); + } + /** Returns no authentication identity. */ async getAuthenticatedEmail(): Promise { return null; diff --git a/packages/gatekeeper-slack/package.json b/packages/gatekeeper-slack/package.json index 50eff551b8..72066c876f 100644 --- a/packages/gatekeeper-slack/package.json +++ b/packages/gatekeeper-slack/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-slack/src/slack.ts b/packages/gatekeeper-slack/src/slack.ts index 1fef47ee37..89026d5d1b 100644 --- a/packages/gatekeeper-slack/src/slack.ts +++ b/packages/gatekeeper-slack/src/slack.ts @@ -5,11 +5,13 @@ import { ApprovalQueue, VendorDescription, GatekeeperConnectCallback, GatekeeperConnectOptions, AccountDescription, SupportedResource, ResourceConfiguratorFrame, ActionKind, Cursor, GatekeeperUserVerifier, ObservationDescription, - stripTrailingSlashes, + stripTrailingSlashes, type ConnectHandoff, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { - SlackApi, SlackApiError, SlackAccessToken, SlackConversationTypeFilter, exchangeAuthCode, - refreshAccessToken, revokeToken, + SlackApi, SlackApiError, SlackAccessToken, SlackConversationTypeFilter, SlackOAuthGrant, + exchangeAuthCode, refreshAccessToken, revokeToken, } from "./slack-api"; import { SlackConversation, SlackConversationEntry, SlackConversationInfo, SlackMessage, @@ -31,6 +33,12 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; const NONCE_BYTES = 32; @@ -183,14 +191,6 @@ const SLACK_LOGO_URL = `data:image/svg+xml,${encodeURIComponent(SLACK_LOGO_SVG)} // ── HTML shown in the OAuth popup ─────────────────────────────────── -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS. - -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -263,12 +263,12 @@ export default { if (!code) return new Response("Error: no 'code' provided"); let stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId)); - if (!await stub.acceptAuthCode(code, oauthNonce)) { + let handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, - { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } else { return new Response("Not Found", { status: 404 }); } @@ -347,16 +347,16 @@ export class UserAccount extends DurableObject { } /** - * Prepare for a reconnect/expansion flow: the next acceptAuthCode() replaces credentials and - * notifies via credentialsRestored() instead of complete(). + * Prepare for a reconnect/expansion flow: the next acceptAuthCode() stages the new credentials + * and notifies via reconnectComplete() instead of complete(); commitReconnect() makes them live. */ async prepareReconnect(initiationNonce: string, requestedScopes: string[]) { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("requestedScopes", requestedScopes); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -378,19 +378,25 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); let scopes = this.ctx.storage.kv.get("requestedScopes") ?? resourceUrlPatternsToScopes(); return { oauthNonce, scopes }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { let stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } // Consume OAuth state before the network exchange to prevent callback replay. this.ctx.storage.kv.delete("nonce"); + let reconnect = stored.reconnect; let completion = await this.#updateCredentials(async () => { if (!this.env.CLIENT_ID || !this.env.CLIENT_SECRET) { @@ -405,27 +411,25 @@ export class UserAccount extends DurableObject { let grant = await exchangeAuthCode( code, this.env.CLIENT_ID, this.env.CLIENT_SECRET, getBaseUrl(this.env) + "/oauth"); - this.ctx.storage.kv.put("accessToken", grant.accessToken); - if (grant.refreshToken) this.ctx.storage.kv.put("refreshToken", grant.refreshToken); - this.ctx.storage.kv.put("grantedScopes", grant.grantedScopes); - this.ctx.storage.kv.put("userId", grant.userId); - this.ctx.storage.kv.put("teamId", grant.teamId); - if (grant.teamName) this.ctx.storage.kv.put("teamName", grant.teamName); + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + let stageId = reconnect + ? stageCredentials(this.ctx.storage.kv, grant, Date.now()) + : undefined; + if (stageId === undefined) this.#writeGrant(grant); this.ctx.storage.kv.delete("requestedScopes"); - - let reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - } - return { callback, grant, reconnecting: !!reconnecting }; + return { callback, grant, stageId }; }); - if (completion.reconnecting) { - await completion.callback.credentialsRestored(completion.grant.accessToken.expires); + let handoff: ConnectHandoff; + if (completion.stageId !== undefined) { + handoff = await completion.callback.reconnectComplete( + completion.stageId, completion.grant.accessToken.expires); } else { try { let props: SlackUserImplProps = { userObjectId: this.ctx.id.toString() }; - await completion.callback.complete( + handoff = await completion.callback.complete( this.ctx.exports.SlackUserImpl({ props }), completion.grant.accessToken.expires); } catch (err) { await this.#updateCredentials(async () => { @@ -438,7 +442,25 @@ export class UserAccount extends DurableObject { throw err; } } - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + await this.#updateCredentials(async () => { + let grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#writeGrant(grant); + }); + } + + #writeGrant(grant: SlackOAuthGrant) { + this.ctx.storage.kv.put("accessToken", grant.accessToken); + if (grant.refreshToken) this.ctx.storage.kv.put("refreshToken", grant.refreshToken); + this.ctx.storage.kv.put("grantedScopes", grant.grantedScopes); + this.ctx.storage.kv.put("userId", grant.userId); + this.ctx.storage.kv.put("teamId", grant.teamId); + if (grant.teamName) this.ctx.storage.kv.put("teamName", grant.teamName); } async getUserId(): Promise { @@ -636,6 +658,10 @@ export class SlackUserImpl extends WorkerEntrypoint return { url: `${getBaseUrl(this.env)}/${this.ctx.props.userObjectId}/${initiationNonce}` }; } + async commitReconnect(stageId: string): Promise { + await this.#account().commitReconnect(stageId); + } + async ensureResources(resourceUrlPatterns: string[]): Promise<{ url?: string }> { let account = this.#account(); let granted = new Set(await account.getGrantedResourceUrlPatterns()); diff --git a/packages/gatekeeper-slack/tsconfig.json b/packages/gatekeeper-slack/tsconfig.json index 178eaee02f..9a1fff1b67 100644 --- a/packages/gatekeeper-slack/tsconfig.json +++ b/packages/gatekeeper-slack/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "target": "ES2022", - "lib": ["ES2023", "ESNext.Disposable"], + "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react", diff --git a/packages/gatekeeper-spotify/package.json b/packages/gatekeeper-spotify/package.json index dd06547fe3..0e520cae19 100644 --- a/packages/gatekeeper-spotify/package.json +++ b/packages/gatekeeper-spotify/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-spotify/src/spotify.ts b/packages/gatekeeper-spotify/src/spotify.ts index f0eb955c78..e34c924e68 100644 --- a/packages/gatekeeper-spotify/src/spotify.ts +++ b/packages/gatekeeper-spotify/src/spotify.ts @@ -5,6 +5,7 @@ import { stripTrailingSlashes, type AccountDescription, type ActionDescription, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperConnectOptions, @@ -16,6 +17,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { SpotifyApi, SpotifyApiError, @@ -72,6 +75,23 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; +}; + +/** + * The live credential keys a grant is written to, held as one unit while a reconnect awaits + * confirmation (see UserAccount.commitReconnect). + */ +type StoredCredentials = { + refreshToken: string; + accessToken: string; + accessTokenExpiresAt: number; + scopes: string[]; }; type ResourceKind = "account" | "playlist"; @@ -129,14 +149,6 @@ const PLAYLIST_RESOURCE: SupportedResource = { const SUPPORTED_RESOURCES: SupportedResource[] = [ACCOUNT_RESOURCE, PLAYLIST_RESOURCE]; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -440,12 +452,12 @@ export default { const stub: DurableObjectStub = ctx.exports.UserAccount.get( ctx.exports.UserAccount.idFromString(doId), ); - const accepted = await stub.acceptAuthCode(code, oauthNonce); - if (!accepted) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); @@ -507,12 +519,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -527,15 +539,20 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); return { oauthNonce, scopes: OAUTH_SCOPES }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -550,20 +567,25 @@ export class UserAccount extends DurableObject { throw new Error("Spotify did not return a refresh token."); } - this.ctx.storage.kv.put("refreshToken", grant.refreshToken); - this.ctx.storage.kv.put("accessToken", grant.accessToken); - this.ctx.storage.kv.put("accessTokenExpiresAt", Date.now() + grant.expiresIn * 1000); - this.ctx.storage.kv.put("scopes", grant.scopes); - this.ctx.storage.kv.put("expiredNotified", false); + const credentials: StoredCredentials = { + refreshToken: grant.refreshToken, + accessToken: grant.accessToken, + accessTokenExpiresAt: Date.now() + grant.expiresIn * 1000, + scopes: grant.scopes, + }; - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, credentials, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.#storeCredentials(credentials); try { const props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (err) { this.ctx.storage.kv.delete("refreshToken"); this.ctx.storage.kv.delete("accessToken"); @@ -572,7 +594,22 @@ export class UserAccount extends DurableObject { } await this.ctx.storage.deleteAlarm(); - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const credentials = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!credentials) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#storeCredentials(credentials); + } + + #storeCredentials(credentials: StoredCredentials): void { + this.ctx.storage.kv.put("refreshToken", credentials.refreshToken); + this.ctx.storage.kv.put("accessToken", credentials.accessToken); + this.ctx.storage.kv.put("accessTokenExpiresAt", credentials.accessTokenExpiresAt); + this.ctx.storage.kv.put("scopes", credentials.scopes); + this.ctx.storage.kv.put("expiredNotified", false); } async getAccessToken(): Promise { @@ -727,6 +764,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#userAccount().commitReconnect(stageId); + } + /** * Mint a verifier representing this account. Spotify uses the "low-stakes" observer strategy (see * SpotifyGatekeeperImpl.addObserver): a personal Spotify account is not the kind of restricted diff --git a/packages/gatekeeper-supabase/package.json b/packages/gatekeeper-supabase/package.json index e96c547037..43d0ae17ad 100644 --- a/packages/gatekeeper-supabase/package.json +++ b/packages/gatekeeper-supabase/package.json @@ -11,6 +11,7 @@ "dependencies": { "@gadgets/backend-utils": "workspace:*", "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-supabase/src/supabase.ts b/packages/gatekeeper-supabase/src/supabase.ts index e4ab50a60d..5b7c38c3a1 100644 --- a/packages/gatekeeper-supabase/src/supabase.ts +++ b/packages/gatekeeper-supabase/src/supabase.ts @@ -4,6 +4,7 @@ import { ApprovalQueue, stripTrailingSlashes, type AccountDescription, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperUser, @@ -14,6 +15,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { SupabaseApi, SupabaseApiError, @@ -21,6 +24,7 @@ import { refreshAccessToken, revokeRefreshToken, type ProjectResponse, + type SupabaseOAuthGrant, } from "./supabase-api"; import { describeTable, listSchemas, listTables } from "./supabase-introspection"; import { @@ -75,6 +79,12 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; type StoredToken = { @@ -82,6 +92,24 @@ type StoredToken = { expiresAt: number; }; +/** + * A grant as persisted (or staged for a reconnect): the expiry is absolute, since the provider's + * relative `expiresIn` counts from the exchange, not from whenever the grant is later made live. + */ +type StoredGrant = { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; +}; + +function toStoredGrant(grant: SupabaseOAuthGrant, now: number): StoredGrant { + return { + accessToken: grant.accessToken, + refreshToken: grant.refreshToken, + accessTokenExpiresAt: now + grant.expiresIn * 1000, + }; +} + // A mutating SQL statement queued for human approval and applied once approved. type StoredExecuteAction = { ref: string; @@ -135,14 +163,6 @@ const ORGANIZATION_RESOURCE: SupportedResource = { const SUPPORTED_RESOURCES: SupportedResource[] = [PROJECT_RESOURCE, ORGANIZATION_RESOURCE]; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -321,12 +341,12 @@ export default { const stub: DurableObjectStub = ctx.exports.UserAccount.get( ctx.exports.UserAccount.idFromString(doId), ); - const accepted = await stub.acceptAuthCode(code, oauthNonce); - if (!accepted) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); @@ -390,12 +410,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -412,15 +432,20 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); return oauthNonce; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } this.ctx.storage.kv.delete("nonce"); @@ -435,18 +460,24 @@ export class UserAccount extends DurableObject { throw new Error("Took too long to complete authorization. Please try again."); } - const grant = await exchangeAuthCode(code, clientId, clientSecret, `${getBaseUrl(this.env)}/oauth`); - this.#storeGrant(grant.accessToken, grant.refreshToken, grant.expiresIn); - this.ctx.storage.kv.put("expiredNotified", false); + const grant = toStoredGrant( + await exchangeAuthCode(code, clientId, clientSecret, `${getBaseUrl(this.env)}/oauth`), + Date.now(), + ); - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, grant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.#storeGrant(grant); + this.ctx.storage.kv.put("expiredNotified", false); try { const props: GatekeeperUserImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (error) { this.ctx.storage.kv.delete("accessToken"); this.ctx.storage.kv.delete("refreshToken"); @@ -456,13 +487,21 @@ export class UserAccount extends DurableObject { } await this.ctx.storage.deleteAlarm(); - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#storeGrant(grant); + this.ctx.storage.kv.put("expiredNotified", false); } - #storeGrant(accessToken: string, refreshToken: string, expiresIn: number): void { - this.ctx.storage.kv.put("accessToken", accessToken); - this.ctx.storage.kv.put("refreshToken", refreshToken); - this.ctx.storage.kv.put("accessTokenExpiresAt", Date.now() + expiresIn * 1000); + #storeGrant(grant: StoredGrant): void { + this.ctx.storage.kv.put("accessToken", grant.accessToken); + this.ctx.storage.kv.put("refreshToken", grant.refreshToken); + this.ctx.storage.kv.put("accessTokenExpiresAt", grant.accessTokenExpiresAt); } /** Returns a valid access token (and its expiry), transparently refreshing when close to expiry. */ @@ -499,9 +538,10 @@ export class UserAccount extends DurableObject { } try { - const grant = await refreshAccessToken(refreshToken, clientId, clientSecret); - this.#storeGrant(grant.accessToken, grant.refreshToken, grant.expiresIn); - return { token: grant.accessToken, expiresAt: Date.now() + grant.expiresIn * 1000 }; + const grant = toStoredGrant( + await refreshAccessToken(refreshToken, clientId, clientSecret), Date.now()); + this.#storeGrant(grant); + return { token: grant.accessToken, expiresAt: grant.accessTokenExpiresAt }; } catch (error) { // A revoked/expired refresh token surfaces as an auth error; record it so the UI prompts a // reconnect rather than surfacing a cryptic failure. @@ -658,6 +698,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#userAccount().commitReconnect(stageId); + } + /** * Mint a verifier representing this account, used by SupabaseGatekeeperImpl.addObserver to confirm * a prospective observer may read a bound project (and, for org bindings, the org and each diff --git a/packages/gatekeeper-zoominfo/package.json b/packages/gatekeeper-zoominfo/package.json index a248abb688..4ac00ae386 100644 --- a/packages/gatekeeper-zoominfo/package.json +++ b/packages/gatekeeper-zoominfo/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@gadgets/configurator-ui": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "capnweb": "catalog:", "capnweb-validate": "catalog:" diff --git a/packages/gatekeeper-zoominfo/src/zoominfo.ts b/packages/gatekeeper-zoominfo/src/zoominfo.ts index 4e89290911..8e5774ccd6 100644 --- a/packages/gatekeeper-zoominfo/src/zoominfo.ts +++ b/packages/gatekeeper-zoominfo/src/zoominfo.ts @@ -4,6 +4,7 @@ import { ApprovalQueue, stripTrailingSlashes, type AccountDescription, + type ConnectHandoff, type Gatekeeper, type GatekeeperConnectCallback, type GatekeeperConnectOptions, @@ -15,6 +16,8 @@ import { type SupportedResource, type VendorDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { connectHandoffPageHtml, htmlResponse } from "@gadgets/gatekeeper-kit/connect-pages"; +import { commitStagedCredentials, stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; import { ZoomInfoApi, ZoomInfoApiError, @@ -95,6 +98,12 @@ type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its grant is staged rather than made + * live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; type StoredIdentity = { @@ -102,6 +111,15 @@ type StoredIdentity = { uniqueName?: string; }; +/** The values a token grant is persisted as; staged whole during a reconnect (see commitReconnect). */ +type StoredGrant = { + refreshToken: string; + accessToken: string; + accessTokenExpiresAt: number; + scopes: string[]; + identity: StoredIdentity; +}; + type ZoomInfoGatekeeperImplProps = { userObjectId: string; }; @@ -153,14 +171,6 @@ const SUPPORTED_RESOURCES: SupportedResource[] = [ACCOUNT_RESOURCE]; // the app home; the binding is whole-account regardless. const ACCOUNT_URL = "https://app.zoominfo.com/"; -const SELF_CLOSING_HTML = ` - - - -

Authorization complete. You may close this tab and return to Cloudflare OS.

- -`; - const INVALID_LINK_HTML = ` Authorization Link Expired @@ -305,12 +315,12 @@ export default { const stub: DurableObjectStub = ctx.exports.UserAccount.get( ctx.exports.UserAccount.idFromString(doId), ); - const accepted = await stub.acceptAuthCode(code, oauthNonce); - if (!accepted) { + const handoff = await stub.acceptAuthCode(code, oauthNonce); + if (!handoff) { return new Response(INVALID_LINK_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } - return new Response(SELF_CLOSING_HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + return htmlResponse(connectHandoffPageHtml(handoff)); } return new Response("Not Found", { status: 404 }); @@ -374,12 +384,12 @@ export class UserAccount extends DurableObject { } async prepareReconnect(initiationNonce: string): Promise { - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -402,19 +412,24 @@ export class UserAccount extends DurableObject { value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: stored.reconnect, }); this.ctx.storage.kv.put("codeVerifier", codeVerifier); return { oauthNonce, codeChallenge }; } - async acceptAuthCode(code: string, oauthNonce: string): Promise { + /** + * Finishes the OAuth code exchange and returns the handoff for the page the browser lands on, or + * null when the callback's nonce doesn't match. + */ + async acceptAuthCode(code: string, oauthNonce: string): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } const codeVerifier = this.ctx.storage.kv.get("codeVerifier"); - if (!codeVerifier) return false; + if (!codeVerifier) return null; this.ctx.storage.kv.delete("nonce"); this.ctx.storage.kv.delete("codeVerifier"); @@ -437,21 +452,26 @@ export class UserAccount extends DurableObject { throw new Error("ZoomInfo did not return a refresh token."); } - this.ctx.storage.kv.put("refreshToken", grant.refreshToken); - this.ctx.storage.kv.put("accessToken", grant.accessToken); - this.ctx.storage.kv.put("accessTokenExpiresAt", Date.now() + grant.expiresIn * 1000); - this.ctx.storage.kv.put("scopes", grant.scopes); - this.ctx.storage.kv.put("identity", parseIdTokenClaims(grant.idToken)); - this.ctx.storage.kv.put("expiredNotified", false); + const storedGrant: StoredGrant = { + refreshToken: grant.refreshToken, + accessToken: grant.accessToken, + accessTokenExpiresAt: Date.now() + grant.expiresIn * 1000, + scopes: grant.scopes, + identity: parseIdTokenClaims(grant.idToken), + }; - const reconnecting = this.ctx.storage.kv.get("reconnecting"); - if (reconnecting) { - this.ctx.storage.kv.delete("reconnecting"); - await callback.credentialsRestored(); + let handoff: ConnectHandoff; + if (stored.reconnect) { + // The reconnect URL is a bearer capability, so the new grant is only staged until the Workshop + // has confirmed the browser that finished the flow is the owner's (see commitReconnect). Bound + // gadgets keep reading the current token meanwhile. + const stageId = stageCredentials(this.ctx.storage.kv, storedGrant, Date.now()); + handoff = await callback.reconnectComplete(stageId); } else { + this.#writeGrant(storedGrant); try { const props: ZoomInfoGatekeeperImplProps = { userObjectId: this.ctx.id.toString() }; - await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); + handoff = await callback.complete(this.ctx.exports.GatekeeperUserImpl({ props })); } catch (err) { this.ctx.storage.kv.delete("refreshToken"); this.ctx.storage.kv.delete("accessToken"); @@ -460,7 +480,23 @@ export class UserAccount extends DurableObject { } await this.ctx.storage.deleteAlarm(); - return true; + return handoff; + } + + /** Makes the grant staged under `stageId` live; see GatekeeperUser.commitReconnect. */ + async commitReconnect(stageId: string): Promise { + const grant = commitStagedCredentials(this.ctx.storage.kv, Date.now(), stageId); + if (!grant) throw new Error("No reconnect is awaiting confirmation. Please try again."); + this.#writeGrant(grant); + } + + #writeGrant(grant: StoredGrant): void { + this.ctx.storage.kv.put("refreshToken", grant.refreshToken); + this.ctx.storage.kv.put("accessToken", grant.accessToken); + this.ctx.storage.kv.put("accessTokenExpiresAt", grant.accessTokenExpiresAt); + this.ctx.storage.kv.put("scopes", grant.scopes); + this.ctx.storage.kv.put("identity", grant.identity); + this.ctx.storage.kv.put("expiredNotified", false); } async getAccessToken(): Promise { @@ -583,6 +619,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { + await this.#userAccount().commitReconnect(stageId); + } + /** * ZoomInfo uses the private-only observer strategy. The verifier is never consulted, but the * overseer mints one on every collaborator open, so getVerifier must still return a valid stub. diff --git a/packages/integration-tests/__tests__/workshop-blueprints.test.ts b/packages/integration-tests/__tests__/workshop-blueprints.test.ts index 3b25de348c..5d783f1fef 100644 --- a/packages/integration-tests/__tests__/workshop-blueprints.test.ts +++ b/packages/integration-tests/__tests__/workshop-blueprints.test.ts @@ -65,7 +65,7 @@ it.concurrent("publishes, instantiates, and deletes an owned blueprint", async ( workspaceTitle: sourceMetadata.title, }, })); - using installedWorkspace = await authenticated.newGadgetFromBlueprint(blueprint.id, {}); + const installedWorkspace = await authenticated.newGadgetFromBlueprint(blueprint.id, {}); const installedMetadata = await installedWorkspace.getMetadata(); const installedGadgetId = installedMetadata.defaultGadgetId; if (installedGadgetId === undefined) throw new Error("Installed workspace has no default Gadget"); @@ -78,6 +78,9 @@ it.concurrent("publishes, instantiates, and deletes an owned blueprint", async ( ? null : true); await installedWorkspace.deleteSelf(); + // Deleting schedules a DO abort; dispose now so the session is told the workspace closed before + // the abort drops the stub, which would otherwise take the shared WebSocket down with it. + installedWorkspace[Symbol.dispose](); await sourceWorkspace.deleteSelf(); }); diff --git a/packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts b/packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts index f87633c9bd..97273789a2 100644 --- a/packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts +++ b/packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts @@ -270,6 +270,10 @@ export class TestAccount throw new Error("The test gatekeeper has no resource configurator; bind a URL directly."); } + commitReconnect(_stageId: string): Promise { + throw new Error("The test gatekeeper has no credentials to reconnect."); + } + reconnect(): Promise<{ url: string }> { throw new Error("The test gatekeeper has no credentials to reconnect."); } diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 91ec6560e6..474f1dc4e2 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { stageCredentials } from "@gadgets/gatekeeper-kit/credential-stage"; + import { McpAuthRequiredError } from "../src/client.js"; import { McpAccountBase, resolveConnectTarget, type AccountEnv, type ConnectedServer, @@ -12,6 +14,7 @@ function fakeContext() { storage: { async deleteAlarm() {}, async setAlarm() {}, + async deleteAll() { values.clear(); }, kv: { get(key: string) { return values.get(key) as T | undefined; }, put(key: string, value: T) { values.set(key, value); }, @@ -112,12 +115,75 @@ class OAuthFlowAccount extends McpAccountBase { _server: ConnectedServer, accessToken: string | null, ): Promise { if (!accessToken) throw new McpAuthRequiredError("authorization required", null); - return { serverInfo: { name: "Acme" } } as never; + // The transport session the server opened for these credentials. + return { + info: { serverInfo: { name: "Acme" } }, sessionId: `session-for-${accessToken}`, + } as never; + } +} + +// A server that answers `initialize` with or without a credential. +class PublicServerAccount extends McpAccountBase { + protected baseUrl(): string { return "https://gatekeeper.example"; } + protected log(): never { return testLog as never; } + protected mintAccount(): never { return {} as never; } + protected override async probe(): Promise { + return { info: { serverInfo: { name: "Acme" } }, sessionId: "public-session" } as never; } } afterEach(() => vi.unstubAllGlobals()); +// An authorization server that registers any client, exchanges any code, and revokes any token, +// recording the bodies of the revocations it is asked for. +function stubOAuthServer(): { revoked: string[] } { + const revoked: string[] = []; + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + const url = String(input); + if (url.includes("oauth-protected-resource")) { + return Response.json({ + resource: "https://mcp.example/mcp", + authorization_servers: ["https://auth.example"], + }); + } + if (url.includes("oauth-authorization-server")) { + return Response.json({ + issuer: "https://auth.example", + authorization_endpoint: "https://auth.example/authorize", + token_endpoint: "https://auth.example/token", + registration_endpoint: "https://auth.example/register", + revocation_endpoint: "https://auth.example/revoke", + response_types_supported: ["code"], + }); + } + if (url === "https://auth.example/revoke") { + revoked.push(String(init?.body)); + return new Response(null, { status: 200 }); + } + if (url === "https://auth.example/register") { + return Response.json({ + client_id: "client-id", + redirect_uris: ["https://gatekeeper.example/oauth"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } + if (url === "https://auth.example/token") { + return Response.json({ + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + expires_in: 3600, + }); + } + return new Response("", { status: 404 }); + }); + return { revoked }; +} + +const HANDOFF = { targetOrigin: "https://workshop.example", ticket: "c".repeat(64) }; + const server = (endpoint: string): ConnectedServer => ({ endpoint, serverId: "acme", @@ -403,7 +469,7 @@ describe("connect initiation nonce", () => { }); const account = new AuthChallengeAccount(context as never, {}); const nonce = "b".repeat(64); - await account.prepareReconnect(nonce); + await account.setCallback({} as never, nonce); await expect(account.beginConnect(nonce, { ...server("https://portal.example/mcp"), auth: "none", provenance: "deployment", @@ -416,43 +482,8 @@ describe("connect initiation nonce", () => { it("completes OAuth after a new account instance resumes the redirect", async () => { const context = fakeContext(); - const complete = vi.fn(async () => undefined); - vi.stubGlobal("fetch", async (input: string) => { - const url = String(input); - if (url.includes("oauth-protected-resource")) { - return Response.json({ - resource: "https://mcp.example/mcp", - authorization_servers: ["https://auth.example"], - }); - } - if (url.includes("oauth-authorization-server")) { - return Response.json({ - issuer: "https://auth.example", - authorization_endpoint: "https://auth.example/authorize", - token_endpoint: "https://auth.example/token", - registration_endpoint: "https://auth.example/register", - response_types_supported: ["code"], - }); - } - if (url === "https://auth.example/register") { - return Response.json({ - client_id: "client-id", - redirect_uris: ["https://gatekeeper.example/oauth"], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }); - } - if (url === "https://auth.example/token") { - return Response.json({ - access_token: "access-token", - refresh_token: "refresh-token", - token_type: "Bearer", - expires_in: 3600, - }); - } - return new Response("", { status: 404 }); - }); + const complete = vi.fn(async () => HANDOFF); + stubOAuthServer(); const nonce = "9".repeat(64); const account = new OAuthFlowAccount(context as never, {}); @@ -463,11 +494,469 @@ describe("connect initiation nonce", () => { const oauthNonce = state.slice(state.indexOf(":") + 1); const resumed = new OAuthFlowAccount(context as never, {}); - expect(await resumed.acceptAuthCode("authorization-code", oauthNonce)).toBe(true); + expect(await resumed.acceptAuthCode("authorization-code", oauthNonce)).toEqual(HANDOFF); expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) .toBe("access-token"); + expect(context.storage.kv.get("mcpSessionId")).toBe("session-for-access-token"); expect(complete).toHaveBeenCalledOnce(); - expect(await resumed.acceptAuthCode("authorization-code", oauthNonce)).toBe(false); + expect(await resumed.acceptAuthCode("authorization-code", oauthNonce)).toBeNull(); + }); + + it("stages a reconnect's tokens until the Workshop commits them", async () => { + // The reconnect URL is a bearer capability, so the tokens it yields must not go live before the + // Workshop has confirmed the finishing browser is the owner's: facets read the live key directly. + const context = fakeContext(); + stubOAuthServer(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + const complete = vi.fn(async () => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { complete, reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + context.storage.kv.put("mcpSessionId", "old-session"); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "7".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + expect(await account.acceptAuthCode("code", state.slice(state.indexOf(":") + 1))) + .toEqual(HANDOFF); + + expect(reconnectComplete).toHaveBeenCalledOnce(); + expect(complete).not.toHaveBeenCalled(); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("old-token"); + // The session the probe opened with the new tokens is staged with them: bound facets still + // read the old tokens, and a session opened under other credentials is not theirs to use. + expect(context.storage.kv.get("mcpSessionId")).toBe("old-session"); + expect(context.storage.kv.get("reconnectTokens")).toBeUndefined(); + // The Workshop was told which stage this completion produced, and only that id commits it. + const stageId = reconnectComplete.mock.calls[0][0]; + expect(stageId).toMatch(/^[0-9a-f]{64}$/); + await expect(account.commitReconnect("0".repeat(64))).rejects.toThrow(/No reconnect is awaiting/); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("old-token"); + expect(context.storage.kv.get("stagedCredentials")).toBeDefined(); + + await account.commitReconnect(stageId); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("access-token"); + expect(context.storage.kv.get("mcpSessionId")).toBe("session-for-access-token"); + expect(context.storage.kv.get("stagedCredentials")).toBeUndefined(); + await expect(account.commitReconnect(stageId)).rejects.toThrow(/No reconnect is awaiting/); + }); + + it("discards and revokes a reconnect's parked tokens when the probe fails after the exchange", async () => { + // The nonce is spent before the exchange, and no alarm sweeps a connected account, so without + // this the grant the exchange parked would sit unused and unrevoked until the next reconnect. + class ProbeFailsAccount extends OAuthFlowAccount { + protected override async probe( + server: ConnectedServer, accessToken: string | null, + ): Promise { + if (accessToken) throw new Error("server rejected the new credentials"); + return await super.probe(server, accessToken); + } + } + const context = fakeContext(); + const { revoked } = stubOAuthServer(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + const account = new ProbeFailsAccount(context as never, {}); + const nonce = "7".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + await expect(account.acceptAuthCode("code", state.slice(state.indexOf(":") + 1))) + .rejects.toThrow("server rejected the new credentials"); + + expect(reconnectComplete).not.toHaveBeenCalled(); + expect(context.storage.kv.get("reconnectTokens")).toBeUndefined(); + expect(context.storage.kv.get("reconnectOauthClient")).toBeUndefined(); + expect(context.storage.kv.get("reconnectOauthDiscovery")).toBeUndefined(); + expect(context.storage.kv.get("stagedCredentials")).toBeUndefined(); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("old-token"); + expect(revoked).toEqual([ + "token=access-token&token_type_hint=access_token&client_id=client-id", + "token=refresh-token&token_type_hint=refresh_token&client_id=client-id", + ]); + }); + + it("stages an overlapping reconnect even after an earlier one is committed", async () => { + // Redeeming reconnect A must not change how reconnect B, already in flight, lands: B's URL may + // be in a phished victim's hands, so B's grant has to stay in escrow until B's own ticket. + const context = fakeContext(); + stubOAuthServer(); + let issued = 0; + const upstream = globalThis.fetch; + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + if (String(input) !== "https://auth.example/token") return upstream(input, init); + issued++; + return Response.json({ + access_token: `token-${issued}`, refresh_token: `refresh-${issued}`, + token_type: "Bearer", expires_in: 3600, + }); + }); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + const complete = vi.fn(async () => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { complete, reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + const account = new OAuthFlowAccount(context as never, {}); + const liveToken = () => + context.storage.kv.get<{ access_token: string }>("tokens")?.access_token; + const startReconnect = async (nonce: string) => { + await account.prepareReconnect(nonce); + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + return state.slice(state.indexOf(":") + 1); + }; + + const a = await startReconnect("1".repeat(64)); + expect(await account.acceptAuthCode("code-a", a)).toEqual(HANDOFF); + const b = await startReconnect("2".repeat(64)); + await account.commitReconnect(reconnectComplete.mock.calls[0][0]); + expect(liveToken()).toBe("token-1"); + + expect(await account.acceptAuthCode("code-b", b)).toEqual(HANDOFF); + expect(reconnectComplete).toHaveBeenCalledTimes(2); + expect(complete).not.toHaveBeenCalled(); + expect(liveToken()).toBe("token-1"); + await account.commitReconnect(reconnectComplete.mock.calls[1][0]); + expect(liveToken()).toBe("token-2"); + }); + + it("re-authorizes a reconnect rather than refreshing the live tokens", async () => { + // The live tokens are refreshable, so `auth()` would refresh them if it saw them — and against a + // server that rotates refresh tokens that burns the live one before the handoff is redeemed. + // A reconnect hides them from the SDK, so it redirects and the live record is untouched. + const context = fakeContext(); + stubOAuthServer(); + const tokenRequests: string[] = []; + const upstream = globalThis.fetch; + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + if (String(input) === "https://auth.example/token") { + tokenRequests.push(String(init?.body)); + } + return upstream(input, init); + }); + const live = { + access_token: "old-token", refresh_token: "old-refresh", token_type: "Bearer", expiresAt: 1, + }; + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { + complete: vi.fn(async () => HANDOFF), reconnectComplete: vi.fn(async () => HANDOFF), + }); + context.storage.kv.put("tokens", live); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "8".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + + expect(outcome.kind).toBe("redirect"); + expect(tokenRequests.filter(body => body.includes("refresh_token"))).toEqual([]); + expect(context.storage.kv.get("tokens")).toEqual(live); + expect(context.storage.kv.get("stagedCredentials")).toBeUndefined(); + }); + + it("keeps a reconnect's client, discovery and server record off the live keys until commit", async () => { + // The tokens were already escrowed, but the SDK also writes the client registration and + // discovery state as it goes, and the flow rewrote the server record mid-way. A reconnect URL in + // the wrong hands could then change what the live account refreshes against, or its name. + const context = fakeContext(); + stubOAuthServer(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + const liveServer = { ...server("https://mcp.example/mcp"), serverName: "Old name" }; + // A registration with another issuer's stamp, so the flow has to register afresh; live discovery + // is not copied at all (see `prepareReconnect`), so the flow rediscovers from the probe. + const liveClient = { client_id: "old-client", issuer: "https://other.example" }; + const liveDiscovery = { authorizationServerUrl: "https://auth.example" }; + context.storage.kv.put("server", liveServer); + context.storage.kv.put("callback", { complete: vi.fn(async () => HANDOFF), reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + context.storage.kv.put("oauthClient", liveClient); + context.storage.kv.put("oauthDiscovery", liveDiscovery); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "9".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + expect(await account.acceptAuthCode("code", state.slice(state.indexOf(":") + 1))) + .toEqual(HANDOFF); + + expect(reconnectComplete).toHaveBeenCalledOnce(); + expect(context.storage.kv.get("server")).toEqual(liveServer); + expect(context.storage.kv.get("oauthClient")).toEqual(liveClient); + expect(context.storage.kv.get("oauthDiscovery")).toEqual(liveDiscovery); + expect(context.storage.kv.get("reconnectOauthClient")).toBeUndefined(); + expect(context.storage.kv.get("reconnectOauthDiscovery")).toBeUndefined(); + + await account.commitReconnect(reconnectComplete.mock.calls[0][0]); + expect(context.storage.kv.get("server")).toEqual({ ...liveServer, serverName: "Acme" }); + expect(context.storage.kv.get<{ client_id: string }>("oauthClient")?.client_id) + .toBe("client-id"); + expect(context.storage.kv.get<{ authorizationServerMetadata?: unknown }>("oauthDiscovery") + ?.authorizationServerMetadata).toBeDefined(); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("access-token"); + }); + + it("rediscovers the authorization server on reconnect", async () => { + // A reconnect is an explicit re-authorization. Seeded with the live discovery, the SDK takes its + // `authorizationServerUrl` verbatim and skips discovery, so an endpoint that moved to another + // authorization server would keep redirecting to the old one. + const context = fakeContext(); + stubOAuthServer(); + const staleDiscovery = { + authorizationServerUrl: "https://stale.example", + authorizationServerMetadata: { + issuer: "https://stale.example", + authorization_endpoint: "https://stale.example/authorize", + token_endpoint: "https://stale.example/token", + response_types_supported: ["code"], + }, + }; + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { + complete: vi.fn(async () => HANDOFF), reconnectComplete: vi.fn(async () => HANDOFF), + }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + context.storage.kv.put("oauthDiscovery", staleDiscovery); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "d".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + expect((outcome as { url: string }).url).toMatch(/^https:\/\/auth\.example\/authorize\?/); + expect(context.storage.kv.get<{ authorizationServerUrl: string }>("reconnectOauthDiscovery") + ?.authorizationServerUrl).toBe("https://auth.example"); + expect(context.storage.kv.get("oauthDiscovery")).toEqual(staleDiscovery); + }); + + it("refuses to commit a reconnect staged before a repoint", async () => { + // Reconnect A staged the old endpoint's record and tokens; a deployment then repointed the + // account. Redeeming A's ticket must not put the old server, tokens and session back live under + // the repoint's probe. + const context = fakeContext(); + const oldServer = { ...server("https://old.example/mcp"), provenance: "deployment" as const }; + const newServer = { ...server("https://new.example/mcp"), provenance: "deployment" as const }; + context.storage.kv.put("server", oldServer); + context.storage.kv.put("tokens", { access_token: "live-token", token_type: "Bearer", expiresAt: 1 }); + const stageIdA = stageCredentials(context.storage.kv, { + tokens: { access_token: "staged-token", token_type: "Bearer", expiresAt: 1 }, + sessionId: "a", + server: oldServer, + }, Date.now()); + const account = new InterleavingAccount(context as never, {}); + const nonce = "e".repeat(64); + await account.prepareReconnect(nonce); + // The repoint runs before the probe's first await. + const repoint = account.beginConnect(nonce, newServer); + + await expect(account.commitReconnect(stageIdA)).rejects.toThrow(/No reconnect is awaiting/); + expect(context.storage.kv.get("server")).toEqual(newServer); + expect(context.storage.kv.get("tokens")).toBeUndefined(); + expect(context.storage.kv.get("mcpSessionId")).toBeUndefined(); + + account.failProbe(); + await expect(repoint).rejects.toThrow("stop test probe"); + }); + + it("registers a reconnect's client only under the reconnect key", async () => { + // With no live registration to copy, dynamic registration during the reconnect must still land + // beside the parked tokens rather than on the key the live refresh path reads. + const context = fakeContext(); + stubOAuthServer(); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { + complete: vi.fn(async () => HANDOFF), reconnectComplete: vi.fn(async () => HANDOFF), + }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "a".repeat(64); + await account.prepareReconnect(nonce); + + expect((await account.beginConnect(nonce, null)).kind).toBe("redirect"); + + expect(context.storage.kv.get("oauthClient")).toBeUndefined(); + expect(context.storage.kv.get("oauthDiscovery")).toBeUndefined(); + expect(context.storage.kv.get<{ client_id: string }>("reconnectOauthClient")?.client_id) + .toBe("client-id"); + expect(context.storage.kv.get("reconnectOauthDiscovery")).toBeDefined(); + }); + + it("does not let a reconnect's refused code exchange invalidate the live client", async () => { + // On `invalid_client` the SDK invalidates the client registration and retries. Pointed at the + // live key, that would have deleted the registration the live tokens still refresh under. + const context = fakeContext(); + stubOAuthServer(); + const upstream = globalThis.fetch; + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + if (String(input) !== "https://auth.example/token") return upstream(input, init); + return Response.json({ error: "invalid_client" }, { status: 401 }); + }); + const liveClient = { client_id: "old-client", issuer: "https://auth.example" }; + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { + complete: vi.fn(async () => HANDOFF), reconnectComplete: vi.fn(async () => HANDOFF), + }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + context.storage.kv.put("oauthClient", liveClient); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "b".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, null); + expect(outcome.kind).toBe("redirect"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + await expect(account.acceptAuthCode("code", state.slice(state.indexOf(":") + 1))) + .rejects.toThrow(); + + expect(context.storage.kv.get("oauthClient")).toEqual(liveClient); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token) + .toBe("old-token"); + }); + + it("stages an observed auth-mode change rather than flipping the live record", async () => { + // An OAuth account whose server now answers unauthenticated: the reconnect learns `"none"`, but + // `getAuthorization()` reads the live mode to decide whether to send the live tokens, so the + // flip must wait for the commit like everything else the reconnect learned. + const context = fakeContext(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { complete: vi.fn(async () => HANDOFF), reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + const account = new PublicServerAccount(context as never, {}); + const nonce = "d".repeat(64); + await account.prepareReconnect(nonce); + + expect((await account.beginConnect(nonce, null)).kind).toBe("done"); + + expect(reconnectComplete).toHaveBeenCalledOnce(); + expect(context.storage.kv.get("server")?.auth).toBe("oauth"); + await account.commitReconnect(reconnectComplete.mock.calls[0][0]); + expect(context.storage.kv.get("server")?.auth).toBe("none"); + expect(context.storage.kv.get("mcpSessionId")).toBe("public-session"); + }); + + it("revokes the retired OAuth grant when a reconnect observes a server that takes none", async () => { + // The stage carries no tokens and no discovery, so the commit would otherwise leave the old + // tokens live while dropping the discovery a later revoke() needs to reach them: a grant nobody + // could ever revoke, disconnect included. + const context = fakeContext(); + const { revoked } = stubOAuthServer(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { reconnectComplete }); + context.storage.kv.put("tokens", { + access_token: "old-token", refresh_token: "old-refresh", token_type: "Bearer", expiresAt: 1, + }); + context.storage.kv.put("oauthClient", { client_id: "client-id" }); + context.storage.kv.put("oauthDiscovery", { + authorizationServerMetadata: { revocation_endpoint: "https://auth.example/revoke" }, + }); + const account = new PublicServerAccount(context as never, {}); + const nonce = "e".repeat(64); + await account.prepareReconnect(nonce); + expect((await account.beginConnect(nonce, null)).kind).toBe("done"); + // Nothing is revoked until the Workshop confirms the reconnect: the old grant still serves. + expect(revoked).toEqual([]); + expect(context.storage.kv.get<{ access_token: string }>("tokens")?.access_token).toBe("old-token"); + + await account.commitReconnect(reconnectComplete.mock.calls[0][0]); + expect(context.storage.kv.get("server")?.auth).toBe("none"); + expect(context.storage.kv.get("tokens")).toBeUndefined(); + expect(context.storage.kv.get("oauthDiscovery")).toBeUndefined(); + expect(revoked).toEqual([ + "token=old-token&token_type_hint=access_token&client_id=client-id", + "token=old-refresh&token_type_hint=refresh_token&client_id=client-id", + ]); + }); + + it("carries a renamed portal through an OAuth reconnect", async () => { + // A deployment restates its portal's name on every connect, and a reconnect adopts it when the + // endpoint is unchanged (see `resolveConnectTarget`). The live record is left alone until the + // commit, so the OAuth callback must stage the record the flow resolved, not rebuild it from + // the live copy, or the rename is silently undone. + const context = fakeContext(); + stubOAuthServer(); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + const portal = (serverName: string): ConnectedServer => ({ + ...server("https://mcp.example/mcp"), provenance: "deployment", serverName, + }); + context.storage.kv.put("server", portal("Old name")); + context.storage.kv.put("callback", { reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + const account = new OAuthFlowAccount(context as never, {}); + const nonce = "9".repeat(64); + await account.prepareReconnect(nonce); + + const outcome = await account.beginConnect(nonce, portal("New name")); + expect(outcome.kind).toBe("redirect"); + expect(context.storage.kv.get("server")?.serverName).toBe("Old name"); + const state = new URL((outcome as { url: string }).url).searchParams.get("state")!; + expect(await account.acceptAuthCode("code", state.slice(state.indexOf(":") + 1))) + .toEqual(HANDOFF); + expect(context.storage.kv.get("server")?.serverName).toBe("Old name"); + + await account.commitReconnect(reconnectComplete.mock.calls[0][0]); + expect(context.storage.kv.get("server")).toMatchObject({ + serverName: "New name", provenance: "deployment", auth: "oauth", + }); + }); + + it("commits the retiring reconnect before the revocation round trip, not after it", async () => { + // The revocation is a network call. A disconnect (or a newer reconnect) that finishes while it + // is in flight must not be overwritten when the commit resumes, so every live write lands first. + const context = fakeContext(); + stubOAuthServer(); + const base = globalThis.fetch; + let releaseRevocation!: () => void; + const revocationStarted = new Promise(started => { + vi.stubGlobal("fetch", async (input: string, init?: RequestInit) => { + if (String(input) !== "https://auth.example/revoke") return await base(input, init); + started(); + await new Promise(release => { releaseRevocation = release; }); + return new Response(null, { status: 200 }); + }); + }); + const reconnectComplete = vi.fn(async (_stageId: string) => HANDOFF); + context.storage.kv.put("server", server("https://mcp.example/mcp")); + context.storage.kv.put("callback", { reconnectComplete }); + context.storage.kv.put("tokens", { access_token: "old-token", token_type: "Bearer", expiresAt: 1 }); + context.storage.kv.put("oauthClient", { client_id: "client-id" }); + context.storage.kv.put("oauthDiscovery", { + authorizationServerMetadata: { revocation_endpoint: "https://auth.example/revoke" }, + }); + const account = new PublicServerAccount(context as never, {}); + const nonce = "e".repeat(64); + await account.prepareReconnect(nonce); + expect((await account.beginConnect(nonce, null)).kind).toBe("done"); + + const commit = account.commitReconnect(reconnectComplete.mock.calls[0][0]); + await revocationStarted; + // Already live while the revocation is still pending. + expect(context.storage.kv.get("server")?.auth).toBe("none"); + expect(context.storage.kv.get("tokens")).toBeUndefined(); + + // The user disconnects during the pause; the resumed commit must leave the account deleted. + await account.revoke(); + expect(context.storage.kv.get("server")).toBeUndefined(); + releaseRevocation(); + await commit; + expect(context.storage.kv.get("server")).toBeUndefined(); + expect(context.storage.kv.get("oauthClient")).toBeUndefined(); + expect(context.storage.kv.get("expiredNotified")).toBeUndefined(); }); }); diff --git a/packages/mcp-shared/__tests__/http.test.ts b/packages/mcp-shared/__tests__/http.test.ts index 3b6c4b4caf..1a583f773b 100644 --- a/packages/mcp-shared/__tests__/http.test.ts +++ b/packages/mcp-shared/__tests__/http.test.ts @@ -4,6 +4,7 @@ import { handleMcpHttpRequest } from "../src/http.js"; const DO_ID = "a".repeat(64); const NONCE = "b".repeat(64); +const HANDOFF = { targetOrigin: "https://workshop.example", ticket: "c".repeat(64) }; const log = { warn() {} } as never; function request(path: string, method = "GET") { @@ -22,7 +23,7 @@ describe("handleMcpHttpRequest", () => { baseUrl: "https://workshop.example/gatekeeper/mcp", accountForId(id) { if (id !== DO_ID) throw new Error("invalid id"); - return { acceptAuthCode: async () => true }; + return { acceptAuthCode: async () => HANDOFF }; }, log, connect: async () => new Response("connected"), @@ -35,7 +36,7 @@ describe("handleMcpHttpRequest", () => { it("delegates a valid connect link without imposing connector method policy", async () => { const response = await handleMcpHttpRequest(request(`/${DO_ID}/${NONCE}`, "POST"), { baseUrl: "https://workshop.example/gatekeeper/mcp", - accountForId: () => ({ acceptAuthCode: async () => true }), + accountForId: () => ({ acceptAuthCode: async () => HANDOFF }), log, connect: async (req, _account, nonce, path) => Response.json({ method: req.method, nonce, path }), @@ -53,12 +54,34 @@ describe("handleMcpHttpRequest", () => { request(`/oauth?code=code&state=${DO_ID}:${NONCE}`), { baseUrl: "https://workshop.example/gatekeeper/mcp", - accountForId: () => ({ acceptAuthCode: async () => true }), + accountForId: () => ({ acceptAuthCode: async () => HANDOFF }), log, connect: async () => new Response("unexpected"), }, ); expect(response.status).toBe(200); + // The page carries the ticket, so it must never be cached or framed. + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'"); + // The page hands the ticket to the Workshop window that opened the flow, and nobody else. + const html = await response.text(); + expect(html).toContain(HANDOFF.ticket); + expect(html).toContain(`postMessage(`); + expect(html).toContain(`"https://workshop.example"`); + }); + + it("treats a rejected OAuth callback as an expired link", async () => { + const response = await handleMcpHttpRequest( + request(`/oauth?code=code&state=${DO_ID}:${NONCE}`), + { + baseUrl: "https://workshop.example/gatekeeper/mcp", + accountForId: () => ({ acceptAuthCode: async () => null }), + log, + connect: async () => new Response("unexpected"), + }, + ); + + expect(response.status).toBe(400); }); }); diff --git a/packages/mcp-shared/__tests__/user.test.ts b/packages/mcp-shared/__tests__/user.test.ts index 5936b21f1d..0a7af21bcf 100644 --- a/packages/mcp-shared/__tests__/user.test.ts +++ b/packages/mcp-shared/__tests__/user.test.ts @@ -15,6 +15,7 @@ const server = { class TestUser extends McpGatekeeperUserBase { revoked = false; + committed: string | undefined; reconnectNonce: string | undefined; protected [mcpGatekeeperUserContext]() { @@ -25,6 +26,7 @@ class TestUser extends McpGatekeeperUserBase { getServer: async () => server, revoke: async () => { this.revoked = true; }, prepareReconnect: async (nonce: string) => { this.reconnectNonce = nonce; }, + commitReconnect: async (stageId: string) => { this.committed = stageId; }, }, }; } @@ -53,6 +55,9 @@ it("provides the common MCP account lifecycle", async () => { `https://workshop.example/gatekeeper/mcp/account-id/${subject.reconnectNonce}`, ); expect(subject.reconnectNonce).toHaveLength(64); + + await subject.commitReconnect("5".repeat(64)); + expect(subject.committed).toBe("5".repeat(64)); }); it("does not expose connector hooks as string-named methods", () => { diff --git a/packages/mcp-shared/package.json b/packages/mcp-shared/package.json index cc91d8efd5..87e7e6d923 100644 --- a/packages/mcp-shared/package.json +++ b/packages/mcp-shared/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@gadgets/backend-utils": "workspace:*", + "@gadgets/gatekeeper-kit": "workspace:*", "@gadgets/workshop-shared": "workspace:*", "@modelcontextprotocol/client": "2.0.0" }, diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index 937396eb41..cb67b9a586 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -13,8 +13,13 @@ // Every nonce is single-use, time-bounded, and compared in constant time; see `connect-nonce.ts`. import { DurableObject } from "cloudflare:workers"; -import type { GatekeeperConnectCallback, GatekeeperUser } +import type { ConnectHandoff, GatekeeperConnectCallback, GatekeeperUser } from "@gadgets/workshop-shared/gatekeeper"; +import { + commitStagedCredentials, + discardStagedCredentials, + stageCredentials, +} from "@gadgets/gatekeeper-kit/credential-stage"; import { auth, refreshAuthorization, @@ -105,24 +110,63 @@ export function resolveConnectTarget( return target ?? existing ?? null; } -/** What `beginConnect` tells the HTTP handler to do next. */ +/** What `beginConnect` tells the HTTP handler to do next; `done` carries the page's handoff. */ export type ConnectOutcome = - | { kind: "done" } + | { kind: "done"; handoff: ConnectHandoff } | { kind: "redirect"; url: string } | { kind: "invalid" }; +// What a reconnect leaves in escrow until the Workshop confirms it (see `commitReconnect`): the new +// tokens (null for a public / preissued-token server that has no credential of its own), the +// transport session the probe opened with them, the server record as the flow observed it (its auth +// mode and reported name), and the OAuth client registration and discovery the tokens were issued +// under, which a later refresh needs. Everything goes live together, so nothing a reconnect learned +// touches the live record before the Workshop has redeemed the ticket. +type StagedReconnect = { + tokens: OAuthTokens | null; + sessionId: string | null; + server: ConnectedServer; + client?: StoredOAuthClientInformation; + discovery?: OAuthDiscoveryState; +}; + +// Where a reconnect's OAuth state waits between the provider's writes and the stage `complete` +// takes: the freshly issued tokens, and the client registration and discovery the flow used. Only +// the registration is seeded from the live one by `prepareReconnect`, so an existing client is +// reused rather than re-registered; discovery starts empty and is redone from the probe's current +// challenge. Never read by anything serving a request; only the flow that wrote them reads them +// back. +const RECONNECT_TOKENS_KEY = "reconnectTokens"; +const RECONNECT_CLIENT_KEY = "reconnectOauthClient"; +const RECONNECT_DISCOVERY_KEY = "reconnectOauthDiscovery"; + // A single-use secret in the connect flow, and the stage it belongs to. type StoredNonce = { value: string; expiresAt: number; stage: "initiation" | "connecting" | "oauth"; + /** + * Set when this flow reconnects an existing account, so its credentials are staged rather than + * made live. The mode travels with the flow instead of living on the account: committing one + * reconnect while another is in flight must not change how that other flow lands. + */ + reconnect?: true; }; +// What `probe` learned: the server's `initialize` result and the transport session it opened, if +// the server uses one. Where the session id is recorded depends on the flow, so the caller keeps +// it. +type Probed = { info: McpServerInfo; sessionId: string | null }; + // OAuth state held between the redirect out and the callback back. type PendingAuthorization = { // Connection generation that started this authorization. The callback crosses an arbitrary time // gap and must not install tokens after a newer reconnect has replaced the attempt. generation: number; + // The server record the flow resolved before redirecting. A reconnect leaves the live record + // untouched until the commit, so this is the only copy that carries a portal's updated metadata + // (a rename, say) across the redirect. Optional for an authorization begun before it was stored. + server?: ConnectedServer; }; /** The environment an account reads. Each Worker's own `Env` satisfies it structurally. */ @@ -258,11 +302,12 @@ export abstract class McpAccountBase * requests both pass and independently probe, start OAuth, or hand an account to the Workshop. * The intermediate stage preserves the value and expiry for diagnosis without leaving it usable. */ - protected claimSelection(initiationNonce: string): boolean { + protected claimSelection(initiationNonce: string): StoredNonce | null { const stored = this.ctx.storage.kv.get("nonce"); - if (!stored || !this.awaitingSelection(initiationNonce)) return false; - this.ctx.storage.kv.put("nonce", { ...stored, stage: "connecting" }); - return true; + if (!stored || !this.awaitingSelection(initiationNonce)) return null; + const claimed: StoredNonce = { ...stored, stage: "connecting" }; + this.ctx.storage.kv.put("nonce", claimed); + return claimed; } // Releases a failed connection attempt without reopening a nonce that another request replaced or @@ -279,12 +324,24 @@ export abstract class McpAccountBase async prepareReconnect(initiationNonce: string): Promise { this.advanceConnectionGeneration(); - this.ctx.storage.kv.put("reconnecting", true); this.ctx.storage.kv.put("expiredNotified", false); + this.ctx.storage.kv.delete(RECONNECT_TOKENS_KEY); + // The flow works on a copy of the client registration, so what it learns (or the SDK + // invalidates) stays off the live key until the commit. Discovery is not copied: a reconnect is + // an explicit re-authorization, so it runs again from the probe's current challenge -- given a + // cached state the SDK takes its `authorizationServerUrl` verbatim, and an endpoint that moved + // to another authorization server would keep redirecting to the old one. The registration is + // still offered, and the provider's `matchesIssuer` drops it when the discovered issuer differs, + // so a moved authorization server gets a fresh registration too. + const client = this.ctx.storage.kv.get("oauthClient"); + if (client === undefined) this.ctx.storage.kv.delete(RECONNECT_CLIENT_KEY); + else this.ctx.storage.kv.put(RECONNECT_CLIENT_KEY, client); + this.ctx.storage.kv.delete(RECONNECT_DISCOVERY_KEY); this.ctx.storage.kv.put("nonce", { value: initiationNonce, expiresAt: Date.now() + INITIATION_NONCE_LIFETIME_MS, stage: "initiation", + reconnect: true, }); } @@ -299,7 +356,9 @@ export abstract class McpAccountBase ): Promise { const existing = this.server(); const server = resolveConnectTarget(existing, target); - if (!server || !this.claimSelection(initiationNonce)) return { kind: "invalid" }; + const claimed = server ? this.claimSelection(initiationNonce) : null; + if (!server || !claimed) return { kind: "invalid" }; + const reconnect = claimed.reconnect === true; // Every claimed attempt advances the generation before its first await, invalidating old probe, // OAuth callback, refresh, and session writes. A repoint additionally persists the new endpoint @@ -308,15 +367,26 @@ export abstract class McpAccountBase // place during that await would let a stale old-endpoint facet pass `getConnection()` and receive // the new portal's token. const generation = this.advanceConnectionGeneration(); - if (existing) this.ctx.storage.kv.delete("mcpSessionId"); + // A plain reconnect leaves the live session alone with the live tokens it was opened under; + // both are replaced together when the Workshop commits (see `commitReconnect`). A repoint drops + // everything minted for the old endpoint, staged or live. const endpointChanged = existing !== undefined && existing.endpoint !== server.endpoint; if (endpointChanged) { this.ctx.storage.kv.put("server", server); + // The reconnect copies go too, or the flow would present the old endpoint's `client_id` to the + // new authorization server. for (const key of [ - "tokens", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", + "tokens", "mcpSessionId", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", + RECONNECT_CLIENT_KEY, RECONNECT_DISCOVERY_KEY, ]) { this.ctx.storage.kv.delete(key); } + // A pending stage holds the old endpoint's record and credentials, and "credentials do not + // survive the move" (see `resolveConnectTarget`) applies to staged ones too: committing it + // would put the old server back live under this probe. Its ticket then fails with "No + // reconnect is awaiting confirmation", which the Workshop treats as a failed restore that + // changed nothing live. + discardStagedCredentials(this.ctx.storage.kv); this.ctx.storage.kv.put("expiredNotified", false); this.log().info("portal repointed", { event: "connect.repointed", @@ -347,7 +417,7 @@ export abstract class McpAccountBase // user input up front left a typo or dead host as the account's permanent choice. A deployment // repoint is the exception above: it must fail closed against old facets before probing. try { - const info = await this.probe(server, null, generation); + const probed = await this.probe(server, null); if (generation !== this.connectionGeneration()) { throw new Error("This connection attempt was replaced by a newer one."); } @@ -356,10 +426,11 @@ export abstract class McpAccountBase // token from every later request. Only an endpoint that answered with no credential at all is. const connected: ConnectedServer = server.auth === "token" ? server : { ...server, auth: "none" }; - this.ctx.storage.kv.put("server", connected); - await this.complete(connected, info, generation); + // A reconnect's observed record rides the stage (see `complete`); the live one is untouched. + if (!reconnect) this.ctx.storage.kv.put("server", connected); + const handoff = await this.complete(connected, probed, generation, reconnect); log.info("connected without authorization", { event: "connect.completed" }); - return { kind: "done" }; + return { kind: "done", handoff }; } catch (err) { if (!(err instanceof McpAuthRequiredError)) { this.restoreSelection(initiationNonce); @@ -379,9 +450,9 @@ export abstract class McpAccountBase // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback // stores. const oauthServer: ConnectedServer = { ...server, auth: "oauth" }; - this.ctx.storage.kv.put("server", oauthServer); + if (!reconnect) this.ctx.storage.kv.put("server", oauthServer); try { - return await this.beginOAuth(oauthServer, err.resourceMetadataUrl, generation); + return await this.beginOAuth(oauthServer, err.resourceMetadataUrl, generation, reconnect); } catch (oauthErr) { this.restoreSelection(initiationNonce); throw oauthErr; @@ -389,24 +460,27 @@ export abstract class McpAccountBase } } - /** Opens a client and performs `initialize`, caching the transport session id it returns. */ - protected async probe( - server: ConnectedServer, accessToken: string | null, generation: number, - ): Promise { + /** + * Opens a client and performs `initialize`. Writes nothing: `complete` records the session id it + * returns, live for a first connect and staged for a reconnect, once the flow is known to still + * be current. + */ + protected async probe(server: ConnectedServer, accessToken: string | null): Promise { const token = accessToken ?? (server.auth === "token" ? this.staticToken(server) : null); const client = new McpClient(server.endpoint, async () => token, null, this.fetchOptions()); const info = await client.initialize(clientName(this.env)); - // A newer attempt may have started while initialize was in flight. Its session belongs to that - // attempt, not this response, so only the captured generation may populate the cache. - if (client.sessionId && generation === this.connectionGeneration()) { - this.ctx.storage.kv.put("mcpSessionId", client.sessionId); - } - return info; + return { info, sessionId: client.sessionId ?? null }; } + // `reconnect` is the flow's mode (see `StoredNonce.reconnect`): a reconnect keeps the SDK away + // from the live tokens in both directions, neither reading nor writing them, and points its client + // registration and discovery at the reconnect copies, so nothing it saves or invalidates reaches + // the live keys before the commit. Only the code verifier is shared, since the nonce and + // `pendingAuth` slots already serialize flows. private oauthProvider( server: ConnectedServer, generation: number, + reconnect: boolean, redirect: (url: URL) => void = () => { throw new Error("The authorization server unexpectedly requested a redirect."); }, @@ -418,6 +492,8 @@ export abstract class McpAccountBase }; const matchesIssuer = (value: { issuer?: string } | undefined, issuer?: string) => value !== undefined && (!issuer || !value.issuer || value.issuer === issuer); + const clientKey = reconnect ? RECONNECT_CLIENT_KEY : "oauthClient"; + const discoveryKey = reconnect ? RECONNECT_DISCOVERY_KEY : "oauthDiscovery"; return { redirectUrl: `${this.baseUrl()}/oauth`, @@ -430,19 +506,24 @@ export abstract class McpAccountBase }, clientInformation: context => { current(); - const client = this.ctx.storage.kv.get("oauthClient"); + const client = this.ctx.storage.kv.get(clientKey); if (client && typeof client.client_id !== "string") { - this.ctx.storage.kv.delete("oauthClient"); + this.ctx.storage.kv.delete(clientKey); return undefined; } return matchesIssuer(client, context?.issuer) ? client : undefined; }, saveClientInformation: (client, context) => { current(); - this.ctx.storage.kv.put("oauthClient", { ...client, issuer: context?.issuer }); + this.ctx.storage.kv.put(clientKey, { ...client, issuer: context?.issuer }); }, tokens: context => { current(); + // A reconnect is a re-authorization, so the SDK must not see — and refresh — the live + // tokens: against a server that rotates refresh tokens, a refresh here would burn the live + // one before the Workshop has redeemed the handoff, leaving bound facets with nothing if it + // never does. With no tokens the SDK redirects to the authorization server instead. + if (reconnect) return undefined; const tokens = this.ctx.storage.kv.get("tokens"); if (tokens && (typeof tokens.access_token !== "string" || typeof tokens.token_type !== "string")) { @@ -453,14 +534,23 @@ export abstract class McpAccountBase }, saveTokens: (tokens, context) => { current(); - this.ctx.storage.kv.put("tokens", { + const stored: OAuthTokens = { ...tokens, issuer: context?.issuer, // An absent `expires_in` is optional per RFC 6749 and means unknown, not eternal. Left // undefined the token is never refreshed, and a refresh token sitting right here goes // unused while every call fails on the server's own 401. expiresAt: Date.now() + (tokens.expires_in ?? DEFAULT_TOKEN_LIFETIME_S) * 1000, - }); + }; + // A reconnect's tokens are parked for `complete` to stage, together with the session the + // probe opens with them, until the Workshop has confirmed the browser that finished the + // flow belongs to the account's owner (`commitReconnect`); the live tokens, which bound + // facets read directly, are untouched until then. + if (reconnect) { + this.ctx.storage.kv.put(RECONNECT_TOKENS_KEY, stored); + return; + } + this.ctx.storage.kv.put("tokens", stored); this.ctx.storage.kv.put("expiredNotified", false); }, redirectToAuthorization: url => { @@ -484,56 +574,57 @@ export abstract class McpAccountBase value: oauthNonce, expiresAt: Date.now() + OAUTH_NONCE_LIFETIME_MS, stage: "oauth", + reconnect: reconnect ? true : undefined, }); - this.ctx.storage.kv.put("pendingAuth", { generation }); + this.ctx.storage.kv.put("pendingAuth", { generation, server }); return `${this.ctx.id.toString()}:${oauthNonce}`; }, discoveryState: () => { current(); - const state = this.ctx.storage.kv.get("oauthDiscovery"); + const state = this.ctx.storage.kv.get(discoveryKey); if (state && typeof state.authorizationServerUrl !== "string") { - this.ctx.storage.kv.delete("oauthDiscovery"); + this.ctx.storage.kv.delete(discoveryKey); return undefined; } return state; }, saveDiscoveryState: state => { current(); - this.ctx.storage.kv.put("oauthDiscovery", state); + this.ctx.storage.kv.put(discoveryKey, state); }, invalidateCredentials: scope => { current(); - if (scope === "all" || scope === "tokens") this.ctx.storage.kv.delete("tokens"); - if (scope === "all" || scope === "client") this.ctx.storage.kv.delete("oauthClient"); - if (scope === "all" || scope === "verifier") this.ctx.storage.kv.delete("oauthVerifier"); - if (scope === "all" || scope === "discovery") { - this.ctx.storage.kv.delete("oauthDiscovery"); + if (scope === "all" || scope === "tokens") { + // While reconnecting the SDK only ever held the parked tokens, so those are what it is + // invalidating; the live ones stay until the Workshop commits. Likewise for the client + // and discovery below. + this.ctx.storage.kv.delete(reconnect ? RECONNECT_TOKENS_KEY : "tokens"); } + if (scope === "all" || scope === "client") this.ctx.storage.kv.delete(clientKey); + if (scope === "all" || scope === "verifier") this.ctx.storage.kv.delete("oauthVerifier"); + if (scope === "all" || scope === "discovery") this.ctx.storage.kv.delete(discoveryKey); }, }; } private async beginOAuth( server: ConnectedServer, resourceMetadataUrl: string | null, generation: number, + reconnect: boolean, ): Promise { const selection = this.ctx.storage.kv.get("nonce"); let redirectUrl: URL | undefined; try { let result: Awaited>; try { - result = await auth(this.oauthProvider(server, generation, url => { redirectUrl = url; }), { + const provider = + this.oauthProvider(server, generation, reconnect, url => { redirectUrl = url; }); + result = await auth(provider, { serverUrl: server.endpoint, resourceMetadataUrl: resourceMetadataUrl ? new URL(resourceMetadataUrl) : undefined, fetchFn: sdkFetch(this.fetchOptions()), }); } catch (err) { - const tokens = this.ctx.storage.kv.get("tokens"); - throw safeOAuthError(err, [ - this.ctx.storage.kv.get("oauthVerifier"), - tokens?.access_token, - tokens?.refresh_token, - ], - this.ctx.storage.kv.get("oauthClient")); + throw this.redactedOAuthError(err, reconnect); } if (!this.isCurrentConnection(server, generation)) { throw new Error("This authorization attempt was replaced by a newer connection."); @@ -545,11 +636,11 @@ export abstract class McpAccountBase return { kind: "redirect", url: redirectUrl.toString() }; } if (result === "AUTHORIZED") { - const tokens = this.ctx.storage.kv.get("tokens"); + const tokens = this.freshTokens(reconnect); if (!tokens) throw new Error("The authorization server returned no access token."); - const info = await this.probe(server, tokens.access_token, generation); - await this.complete(server, info, generation); - return { kind: "done" }; + const probed = await this.probe(server, tokens.access_token); + const handoff = await this.complete(server, probed, generation, reconnect); + return { kind: "done", handoff }; } throw new Error("The authorization server returned no redirect."); } catch (err) { @@ -567,55 +658,177 @@ export abstract class McpAccountBase } } - /** Completes the OAuth code exchange. Returns false when the callback's nonce doesn't match. */ - async acceptAuthCode(code: string, oauthNonce: string, issuer?: string): Promise { + /** + * Completes the OAuth code exchange, returning the handoff for the page the browser lands on. + * Returns null when the callback's nonce doesn't match. + */ + async acceptAuthCode( + code: string, oauthNonce: string, issuer?: string, + ): Promise { const stored = this.ctx.storage.kv.get("nonce"); if (!stored || stored.stage !== "oauth" || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, oauthNonce)) { - return false; + return null; } const pending = this.ctx.storage.kv.get("pendingAuth"); - if (!pending) return false; - const server = this.requireServer(); + if (!pending) return null; + const reconnect = stored.reconnect === true; + // The record the flow resolved before the redirect, not the live one: a reconnect leaves the + // live record alone until the commit, and rebuilding from it would stage a renamed portal under + // its old name. The challenge that started this flow made OAuth the observed auth mode, restated + // here for the record this flow stages (the live one may still say `"none"`). + const server: ConnectedServer = { ...(pending.server ?? this.requireServer()), auth: "oauth" }; // Single-use: consumed before the exchange, so a replayed callback cannot reach the token endpoint. this.ctx.storage.kv.delete("nonce"); this.ctx.storage.kv.delete("pendingAuth"); - if (!this.isCurrentConnection(server, pending.generation)) return false; + if (!this.isCurrentConnection(server, pending.generation)) return null; let result: Awaited>; try { - result = await auth(this.oauthProvider(server, pending.generation), { + result = await auth(this.oauthProvider(server, pending.generation, reconnect), { serverUrl: server.endpoint, authorizationCode: code, iss: issuer, fetchFn: sdkFetch(this.fetchOptions()), }); } catch (err) { - const tokens = this.ctx.storage.kv.get("tokens"); - throw safeOAuthError(err, [ - code, - this.ctx.storage.kv.get("oauthVerifier"), - tokens?.access_token, - tokens?.refresh_token, - ], - this.ctx.storage.kv.get("oauthClient")); + throw this.redactedOAuthError(err, reconnect, code); } if (result !== "AUTHORIZED") throw new Error("The authorization server requested another redirect."); - if (!this.isCurrentConnection(server, pending.generation)) return false; - const tokens = this.ctx.storage.kv.get("tokens"); + if (!this.isCurrentConnection(server, pending.generation)) return null; + const tokens = this.freshTokens(reconnect); if (!tokens) throw new Error("The authorization server returned no access token."); this.ctx.storage.kv.delete("oauthVerifier"); - const info = await this.probe(server, tokens.access_token, pending.generation); - if (!this.isCurrentConnection(server, pending.generation)) return false; - await this.complete(server, info, pending.generation); - return true; + try { + const probed = await this.probe(server, tokens.access_token); + if (!this.isCurrentConnection(server, pending.generation)) return null; + return await this.complete(server, probed, pending.generation, reconnect); + } catch (err) { + // A first connect that fails here is deleted by the abandonment alarm. A reconnect is on a + // connected account, which the alarm leaves alone, so the grant the exchange parked would + // otherwise sit unused and unrevoked until the next reconnect overwrote it. + if (reconnect) await this.discardParkedReconnect(server, pending.generation); + throw err; + } } - // Hands the freshly-minted account back to the Workshop (or, on reconnect, just says so). - private async complete( - server: ConnectedServer, info: McpServerInfo, generation: number, + // Removes and returns what a reconnect flow parked off the live keys: the tokens `saveTokens` + // stored, and the client registration and discovery the flow used. + private takeParkedReconnect(): { + tokens: OAuthTokens | undefined; + client: StoredOAuthClientInformation | undefined; + discovery: OAuthDiscoveryState | undefined; + } { + const take = (key: string): T | undefined => { + const value = this.ctx.storage.kv.get(key); + this.ctx.storage.kv.delete(key); + return value; + }; + return { + tokens: take(RECONNECT_TOKENS_KEY), + client: take(RECONNECT_CLIENT_KEY), + discovery: take(RECONNECT_DISCOVERY_KEY), + }; + } + + // Drops a reconnect's parked grant when its flow failed after the exchange, revoking the tokens + // best-effort. Only while the flow's connection is still current: a newer `prepareReconnect` + // has reset the scratch keys and owns whatever is under them now. + private async discardParkedReconnect(server: ConnectedServer, generation: number): Promise { + if (!this.isCurrentConnection(server, generation)) return; + const { tokens, client, discovery } = this.takeParkedReconnect(); + if (tokens && discovery && client) await this.revokeTokens(tokens, discovery, client); + } + + // Best effort: a server that does not implement RFC 7009 must not block a disconnect. + private async revokeTokens( + tokens: OAuthTokens, discovery: OAuthDiscoveryState, client: StoredOAuthClientInformation, ): Promise { + try { + const fetchFn = sdkFetch(this.fetchOptions()); + await revokeToken(discovery, client, tokens.access_token, "access_token", fetchFn); + if (tokens.refresh_token) { + await revokeToken(discovery, client, tokens.refresh_token, "refresh_token", fetchFn); + } + } catch (err) { + this.log().warn("failed to revoke MCP tokens", + { event: "oauth.token.revoke.failed", error: err }); + } + } + + // The tokens `saveTokens` just stored: live for a first connect, parked for a reconnect. + private freshTokens(reconnect: boolean): OAuthTokens | undefined { + return this.ctx.storage.kv.get(reconnect ? RECONNECT_TOKENS_KEY : "tokens"); + } + + // Strips from an SDK error every secret the flow could have echoed: the verifier, the live tokens, + // and -- for a reconnect -- the parked tokens and the client registration it actually presented. + private redactedOAuthError(err: unknown, reconnect: boolean, ...secrets: string[]): Error { + const kv = this.ctx.storage.kv; + const tokens = kv.get("tokens"); + const parked = reconnect ? kv.get(RECONNECT_TOKENS_KEY) : undefined; + return safeOAuthError(err, [ + ...secrets, + kv.get("oauthVerifier"), + tokens?.access_token, + tokens?.refresh_token, + parked?.access_token, + parked?.refresh_token, + ], kv.get(reconnect ? RECONNECT_CLIENT_KEY : "oauthClient")); + } + + /** + * Makes the credentials staged under `stageId` live (see `GatekeeperUser.commitReconnect`). + * Throws when no reconnect awaits confirmation, its stage has expired, or a different one is + * staged now. + */ + async commitReconnect(stageId: string): Promise { + const staged = commitStagedCredentials( + this.ctx.storage.kv, Date.now(), stageId); + if (!staged) throw new Error("No reconnect is awaiting confirmation. Please try again."); + // A repoint discards the stage (see `beginConnect`); the stage's own record says which endpoint + // it was for, so one that somehow outlives a move still cannot restore the old server. + if (!sameEndpoint(staged.server.endpoint, this.requireServer().endpoint)) { + throw new Error("No reconnect is awaiting confirmation. Please try again."); + } + const kv = this.ctx.storage.kv; + // The reconnect observed a server that takes no OAuth credential, so the live grant is retired + // rather than replaced. It is revoked below, with the discovery and client it was issued under, + // which this commit drops -- after which revoke() could no longer reach it. + const retired = staged.tokens ? null : { + tokens: kv.get("tokens"), + discovery: kv.get("oauthDiscovery"), + client: kv.get("oauthClient"), + }; + if (staged.tokens) kv.put("tokens", staged.tokens); + else kv.delete("tokens"); + // The live session was opened under the credentials being replaced, so it goes with them, as do + // the server record the flow observed and the registration and discovery a refresh will need. + this.setSessionId(staged.sessionId ?? null); + kv.put("server", staged.server); + if (staged.client) kv.put("oauthClient", staged.client); + else kv.delete("oauthClient"); + if (staged.discovery) kv.put("oauthDiscovery", staged.discovery); + else kv.delete("oauthDiscovery"); + kv.put("expiredNotified", false); + // Only now, with every live write done: the revocation is a network round trip, and a newer + // reconnect or a disconnect that finished during it must not be overwritten when this resumes. + if (retired?.tokens && retired.discovery && retired.client) { + await this.revokeTokens(retired.tokens, retired.discovery, retired.client); + } + } + + private setSessionId(sessionId: string | null): void { + if (sessionId) this.ctx.storage.kv.put("mcpSessionId", sessionId); + else this.ctx.storage.kv.delete("mcpSessionId"); + } + + // Hands the freshly-minted account back to the Workshop (or, on reconnect, just says so), and + // returns the handoff for the page the browser lands on. + private async complete( + server: ConnectedServer, { info, sessionId }: Probed, generation: number, reconnect: boolean, + ): Promise { if (!this.isCurrentConnection(server, generation)) { throw new Error("This connection attempt was replaced by a newer one."); } @@ -626,9 +839,9 @@ export abstract class McpAccountBase // the user chose. A deployment-configured endpoint has an administrator's name on it, and letting // the far side rename itself in every approval prompt would undo that choice. const reported = displayName(info.serverInfo?.title ?? info.serverInfo?.name); - if (reported && server.provenance === "user") { - this.ctx.storage.kv.put("server", { ...server, serverName: reported }); - } + const observed: ConnectedServer = + reported && server.provenance === "user" ? { ...server, serverName: reported } : server; + if (!reconnect) this.ctx.storage.kv.put("server", observed); // The initiation nonce authorized exactly one connect, which has now happened. Left in place, a // replayed connect URL could mint a second account against the same callback. @@ -640,14 +853,30 @@ export abstract class McpAccountBase // connection the user can see and cannot use. this.ctx.storage.kv.put("connected", true); - if (this.ctx.storage.kv.get("reconnecting")) { - this.ctx.storage.kv.delete("reconnecting"); - const expiresAt = this.ctx.storage.kv.get("tokens")?.expiresAt; - await callback.credentialsRestored(expiresAt ? new Date(expiresAt) : undefined); + let handoff: ConnectHandoff; + if (reconnect) { + // Everything the commit needs goes under one stage id: the tokens `saveTokens` parked (none + // for a server with no credential of its own, staged all the same so the commit still has + // something to confirm), the session the probe opened with them, the server record as this + // flow observed it, and the client registration and discovery the flow used. The live record + // has not been touched, and is not until the Workshop names this id in `commitReconnect`. + const parked = this.takeParkedReconnect(); + const tokens = parked.tokens ?? null; + const stageId = stageCredentials(this.ctx.storage.kv, { + tokens, + sessionId, + server: observed, + client: parked.client, + discovery: parked.discovery, + }, Date.now()); + handoff = await callback.reconnectComplete( + stageId, tokens?.expiresAt ? new Date(tokens.expiresAt) : undefined); } else { - await callback.complete(this.mintAccount()); + this.setSessionId(sessionId); + handoff = await callback.complete(this.mintAccount()); } await this.ctx.storage.deleteAlarm(); + return handoff; } @@ -876,19 +1105,7 @@ export abstract class McpAccountBase const tokens = this.ctx.storage.kv.get("tokens"); const discovery = this.ctx.storage.kv.get("oauthDiscovery"); const client = this.ctx.storage.kv.get("oauthClient"); - if (tokens && discovery && client) { - // Best effort: a server that does not implement RFC 7009 must not block the disconnect. - try { - const fetchFn = sdkFetch(this.fetchOptions()); - await revokeToken(discovery, client, tokens.access_token, "access_token", fetchFn); - if (tokens.refresh_token) { - await revokeToken(discovery, client, tokens.refresh_token, "refresh_token", fetchFn); - } - } catch (err) { - this.log().warn("failed to revoke MCP tokens", - { event: "oauth.token.revoke.failed", error: err }); - } - } + if (tokens && discovery && client) await this.revokeTokens(tokens, discovery, client); await this.ctx.storage.deleteAlarm(); await this.ctx.storage.deleteAll(); } diff --git a/packages/mcp-shared/src/html.ts b/packages/mcp-shared/src/html.ts index 92fab569f3..671a7b745b 100644 --- a/packages/mcp-shared/src/html.ts +++ b/packages/mcp-shared/src/html.ts @@ -1,85 +1,18 @@ -// The pages every MCP gatekeeper serves in a browser tab during connect: "you can close this -// window", "that link expired", and "it didn't work, here's why". +// The pages every MCP gatekeeper serves in a browser tab during connect: "connected" (the kit's +// handoff page), "that link expired", and "it didn't work, here's why". All of it comes from the +// kit, re-exported so both connectors take it from one place: its `htmlResponse` is the hardened +// one (no caching, no framing, no referrer), which matters because the handoff page carries the +// ticket. // // Anything a gatekeeper asks the user is *not* here. Only `gatekeeper-mcp` has a question to ask (see // its `connect-form.ts`); the gateway's endpoint is a deployment setting, so it has no form and would // carry the form's markup and CSS for nothing. -export function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, char => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); -} - -export function htmlResponse(body: string, status = 200): Response { - return new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); -} - -/** - * The palette and page frame every connect page shares. - * - * These pages open in their own browser tab, outside the Workshop, so they cannot reach Tailwind or - * Kumo. The tokens are copied from `workshop-frontend/src/styles.css` (both palettes) so the tab - * still reads as the same product. Only the base palette is copied: a deployment's admin-chosen - * accent lives in the Workshop's AdminConfig, which a gatekeeper has no business reading. - * - * Form controls are absent, since a gatekeeper with a form appends its own rules, which is why the - * tokens are CSS variables rather than literals. - */ -export const PAGE_STYLE = ` - :root { - color-scheme: light dark; - --font: "FT Kunst Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", sans-serif; - --base: #fcfcfb; - --control: #ffffff; - --line: #e8e7e4; - --text: #1c1a18; - --strong: #100f0d; - --subtle: oklch(52% 0.006 60); - --brand: #ff4801; - --danger: oklch(63.7% 0.237 25.331); - /* Kumo's primary button is "contrast": near-black in light mode, the accent in dark. */ - --contrast: #14110f; - --on-contrast: #ffffff; - } - @media (prefers-color-scheme: dark) { - :root { - --base: oklch(0.115 0.012 285); - --control: oklch(0.155 0.011 285); - --line: oklch(0.34 0.022 285); - --text: oklch(0.92 0.01 285); - --strong: oklch(0.92 0.01 285); - --subtle: oklch(0.66 0.02 285); - --brand: #b84e00; - --danger: oklch(70.4% 0.191 22.216); - --contrast: #b84e00; - } - } - - body { font: 15px/1.5 var(--font); margin: 0; padding: 48px 20px; display: flex; - justify-content: center; background: var(--base); color: var(--text); - -webkit-font-smoothing: antialiased; } - main { width: 100%; max-width: 420px; } - h1 { font-size: 17px; font-weight: 600; color: var(--strong); margin: 0 0 6px; - letter-spacing: -0.01em; } - p.sub { margin: 0 0 24px; color: var(--subtle); font-size: 14px; } - p.err { color: var(--danger); font-size: 13px; margin: 0 0 16px; } -`; - -export const SELF_CLOSING_HTML = ` -Connected -

Connected. You can close this window.

`; - -export const INVALID_LINK_HTML = ` -Link expired -

This link has expired

-

Start the connection again.

`; - -/** A minimal page reporting that connecting failed, with a reason the user can act on. */ -export function errorPageHtml(title: string, detail: string): string { - return ` - -${escapeHtml(title)} -

${escapeHtml(title)}

-

${escapeHtml(detail)}

`; -} +export { + connectHandoffPageHtml, + errorPageHtml, + escapeHtml, + htmlResponse, + INVALID_LINK_HTML, + PAGE_STYLE, +} from "@gadgets/gatekeeper-kit/connect-pages"; diff --git a/packages/mcp-shared/src/http.ts b/packages/mcp-shared/src/http.ts index 54eda10590..fc0aa7c004 100644 --- a/packages/mcp-shared/src/http.ts +++ b/packages/mcp-shared/src/http.ts @@ -1,15 +1,16 @@ -import { stripTrailingSlashes } from "@gadgets/workshop-shared/gatekeeper"; +import { stripTrailingSlashes, type ConnectHandoff } from "@gadgets/workshop-shared/gatekeeper"; import { NONCE_BYTES } from "./connect-nonce.js"; import { + connectHandoffPageHtml, errorPageHtml, htmlResponse, INVALID_LINK_HTML, - SELF_CLOSING_HTML, } from "./html.js"; import type { McpLog } from "./log.js"; type OAuthCallbackAccount = { - acceptAuthCode(code: string, nonce: string, issuer?: string): Promise; + /** Finishes the code exchange; null when the callback's nonce doesn't match. */ + acceptAuthCode(code: string, nonce: string, issuer?: string): Promise; }; async function handleOAuthCallback( @@ -36,16 +37,17 @@ async function handleOAuthCallback( return htmlResponse(INVALID_LINK_HTML, 400); } + let handoff: ConnectHandoff | null; try { - const accepted = await account.acceptAuthCode( + handoff = await account.acceptAuthCode( code, state.slice(separator + 1), url.searchParams.get("iss") ?? undefined); - if (!accepted) return htmlResponse(INVALID_LINK_HTML, 400); } catch (err) { log.warn("oauth code exchange failed", { event: "connect.oauth.failed", error: err }); return htmlResponse(errorPageHtml( "Could not finish connecting", err instanceof Error ? err.message : String(err)), 502); } - return htmlResponse(SELF_CLOSING_HTML); + if (!handoff) return htmlResponse(INVALID_LINK_HTML, 400); + return htmlResponse(connectHandoffPageHtml(handoff)); } /** Routes the HTTP paths common to both MCP connectors. */ diff --git a/packages/mcp-shared/src/user.ts b/packages/mcp-shared/src/user.ts index 13912e851a..0431b54d9b 100644 --- a/packages/mcp-shared/src/user.ts +++ b/packages/mcp-shared/src/user.ts @@ -15,6 +15,8 @@ export interface McpGatekeeperUserAccount { revoke(): Promise; /** Starts a reconnect with a fresh initiation nonce. */ prepareReconnect(initiationNonce: string): Promise; + /** Makes the credentials staged under `stageId` live. */ + commitReconnect(stageId: string): Promise; } /** Connector-owned values used by the common MCP account lifecycle. */ @@ -63,6 +65,11 @@ export abstract class McpGatekeeperUserBase await this[mcpGatekeeperUserContext]().account.revoke(); } + /** Makes the credentials staged under `stageId` live (see GatekeeperUser.commitReconnect). */ + async commitReconnect(stageId: string): Promise { + await this[mcpGatekeeperUserContext]().account.commitReconnect(stageId); + } + /** Starts reconnecting the connected account. */ async reconnect(): Promise<{ url: string }> { const { account, baseUrl } = this[mcpGatekeeperUserContext](); diff --git a/packages/workshop-backend/__tests__/connect-handoff.test.ts b/packages/workshop-backend/__tests__/connect-handoff.test.ts new file mode 100644 index 0000000000..792919acb3 --- /dev/null +++ b/packages/workshop-backend/__tests__/connect-handoff.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import { runInDurableObject } from "cloudflare:test"; +import type { GatekeeperUser } from "@gadgets/workshop-shared/gatekeeper"; +import type { GatekeeperConnectCallbackImpl, UserDurableObject } from "../src/user.js"; +import { handoffTargetOrigin, PENDING_HANDOFF_LIFETIME_MS } from "../src/connect-handoff.js"; +import type { FakeGatekeeperAccount } from "./test-worker.js"; + +declare module "cloudflare:workers" { + interface ProvidedEnv { + TEST_USER: DurableObjectNamespace; + } +} + +const TARGET = "https://workshop.example"; +const EXPIRED = "This connection attempt has expired. Please try again."; + +// What a test reaches into the user DO for: the typed collections behind the public methods. +type UserInternals = UserDurableObject & { + storage: { + connectedAccounts: { get(id: number): Record | undefined; put(record: unknown): void }; + pendingHandoffs: { list(): Iterable<{ expiresAt: Date }>; put(record: unknown): void }; + nextAccountId: { get(): number; put(n: number): void }; + }; + ctx: DurableObjectState & { + exports: { + FakeGatekeeperAccount(options: { props: FakeAccountProps }): Fetcher; + TestConnectCallback(options: { + props: { userId: string; accountId: number; vendorId: string }; + }): Fetcher; + }; + }; +}; + +type FakeAccountProps = { name: string; failRevoke?: boolean; failDescribe?: boolean }; + +let userCounter = 0; +function freshUser() { + const stub = env.TEST_USER.getByName(`connect-handoff-${++userCounter}`); + return { + stub, + inDo(f: (user: UserInternals) => Promise): Promise { + return runInDurableObject(stub, (instance: UserDurableObject) => f(instance as UserInternals)); + }, + }; +} + +// An account stub the DO can persist (a WorkerEntrypoint reached through ctx.exports, like a real +// gatekeeper's), viewed as the GatekeeperUser the kernel expects. +function fakeAccount(user: UserInternals, name: string, failing?: Omit) { + const account = user.ctx.exports.FakeGatekeeperAccount({ props: { name, ...failing } }); + return { account: account as unknown as Fetcher, calls: () => account.calls() }; +} + +const STAGE_ID = "5".repeat(64); + +// Redeems over the user's stub the way the browser does, reporting the outcome as a value: a native +// RPC promise left to `.rejects` is also flagged as an unhandled rejection by the pool. +async function redeem(stub: DurableObjectStub, ticket: string): Promise { + try { + await stub.completeConnectHandoff(ticket); + return "ok"; + } catch (err) { + return (err as Error).message; + } +} + +function pendingCount(user: UserInternals) { + return [...user.storage.pendingHandoffs.list()].length; +} + +// Age every pending record past its lifetime, as the alarm would find them. +function expirePending(user: UserInternals) { + // Snapshot first: a put during kv.list() invalidates the iterator. + const records = Array.from(user.storage.pendingHandoffs.list()); + for (const record of records) { + user.storage.pendingHandoffs.put({ ...record, expiresAt: new Date(Date.now() - 1) }); + } +} + +describe("connect handoff", () => { + it("stages a connect and activates it only when its ticket is redeemed", async () => { + const { stub, inDo } = freshUser(); + const handoff = await inDo(async user => { + const { account } = fakeAccount(user, "octocat"); + user.storage.nextAccountId.put(1); + const staged = await user.stagePendingConnect(0, account, "github", new Date("2027-01-01")); + expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); + const [pending] = Array.from(user.storage.pendingHandoffs.list()); + expect(pending).toBeDefined(); + expect(pendingCount(user)).toBe(1); + // The sweep is armed for the record's expiry, which is within the lifetime. + expect(await user.ctx.storage.getAlarm()).toBe(pending.expiresAt.getTime()); + expect(pending.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(PENDING_HANDOFF_LIFETIME_MS); + // Only the ticket's hash is at rest. + for (const [, value] of user.ctx.storage.kv.list()) { + expect(JSON.stringify(value)).not.toContain(staged.ticket); + } + return staged; + }); + expect(handoff.targetOrigin).toBe(TARGET); + expect(handoff.ticket).toMatch(/^[0-9a-f]{64}$/); + + // Redeemed the way the browser does it: over the user's own stub. + await stub.completeConnectHandoff(handoff.ticket); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)).toMatchObject({ + id: 0, vendorId: "github", description: { displayName: "octocat" }, + credentialExpiresAt: new Date("2027-01-01"), + }); + expect(await fakeAccount(user, "octocat").calls()).toEqual(["describe"]); + expect(pendingCount(user)).toBe(0); + }); + }); + + it("rejects a ticket that is unknown, malformed, already redeemed, or another user's", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(user => + user.stagePendingConnect(0, fakeAccount(user, "a").account, "github")); + + expect(await redeem(stub, "f".repeat(64))).toBe(EXPIRED); + expect(await redeem(stub, "not-a-ticket")).toBe(EXPIRED); + expect(await redeem(stub, ticket.toUpperCase())).toBe(EXPIRED); + // The victim's session: a different user's DO knows nothing of the attacker's ticket. + expect(await redeem(freshUser().stub, ticket)).toBe(EXPIRED); + // A failed redemption leaves the ticket redeemable by the right user... + expect(await redeem(stub, ticket)).toBe("ok"); + // ...exactly once. + expect(await redeem(stub, ticket)).toBe(EXPIRED); + }); + + it("refuses an expired ticket, revoking the unconfirmed grant whether redeemed or swept", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(user => + user.stagePendingConnect(0, fakeAccount(user, "expired").account, "github")); + await inDo(async user => { + await user.stagePendingConnect(1, fakeAccount(user, "swept").account, "github"); + expirePending(user); + }); + + expect(await redeem(stub, ticket)).toBe(EXPIRED); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); + // The refused redemption consumed its record — and revoked the grant it could no longer + // activate, which the alarm would otherwise never see; the alarm sweeps the other. + expect(await fakeAccount(user, "expired").calls()).toEqual(["describe", "revoke"]); + expect(pendingCount(user)).toBe(1); + await user.alarm(); + expect(pendingCount(user)).toBe(0); + expect(await fakeAccount(user, "swept").calls()).toEqual(["describe", "revoke"]); + expect(await user.ctx.storage.getAlarm()).toBeNull(); + }); + }); + + it("revokes a staged connect it failed to persist, and reports the failure", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(async user => { + user.storage.nextAccountId.put(1); + // The same identity is already connected, so persisting runs the dedupe path, whose revoke of + // the duplicate grant fails here — the one way putConnectedAccount itself can throw. + user.storage.connectedAccounts.put({ + id: 0, account: fakeAccount(user, "dup").account, vendorId: "github", + description: { displayName: "dup", uniqueName: "dup" }, + }); + return user.stagePendingConnect(1, fakeAccount(user, "dup", { failRevoke: true }).account, "github"); + }); + + expect(await redeem(stub, ticket)).toBe("revoke failed"); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(1)).toBeUndefined(); + expect(pendingCount(user)).toBe(0); + // The dedupe revoke that threw, then the best-effort revoke of the dropped grant. + expect(await fakeAccount(user, "dup").calls()).toEqual(["describe", "revoke", "revoke"]); + }); + // The ticket was consumed by the attempt. + expect(await redeem(stub, ticket)).toBe(EXPIRED); + }); + + it("commits a staged reconnect and then marks the credentials restored", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(async user => { + const { account } = fakeAccount(user, "renewed"); + user.storage.nextAccountId.put(1); + user.storage.connectedAccounts.put({ + id: 0, account, vendorId: "github", description: { displayName: "old" }, + credentialsExpired: true, + }); + const handoff = await user.stagePendingRestore(0, STAGE_ID, new Date("2027-06-01")); + expect(await fakeAccount(user, "renewed").calls()).toEqual([]); + expect(user.storage.connectedAccounts.get(0)?.credentialsExpired).toBe(true); + return handoff; + }); + + await stub.completeConnectHandoff(ticket); + await inDo(async user => { + // The commit names the stage this ticket was minted for, not "whatever is staged". + expect(await fakeAccount(user, "renewed").calls()) + .toEqual([`commitReconnect(${STAGE_ID})`, "describe"]); + expect(user.storage.connectedAccounts.get(0)).toMatchObject({ + credentialsExpired: false, credentialExpiresAt: new Date("2027-06-01"), + description: { displayName: "renewed" }, + }); + }); + }); + + it("marks a committed reconnect restored even when the description cannot be refreshed", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(async user => { + user.storage.nextAccountId.put(1); + user.storage.connectedAccounts.put({ + id: 0, account: fakeAccount(user, "stale", { failDescribe: true }).account, vendorId: "github", + description: { displayName: "old" }, credentialsExpired: true, + }); + return user.stagePendingRestore(0, STAGE_ID, new Date("2027-06-01")); + }); + + // The credentials went live at the commit; a failed describe() must not leave the account + // showing as expired, which would send the user back through a reconnect that changes nothing. + expect(await redeem(stub, ticket)).toBe("ok"); + await inDo(async user => { + expect(await fakeAccount(user, "stale").calls()) + .toEqual([`commitReconnect(${STAGE_ID})`, "describe"]); + expect(user.storage.connectedAccounts.get(0)).toMatchObject({ + credentialsExpired: false, credentialExpiresAt: new Date("2027-06-01"), + description: { displayName: "old" }, + }); + }); + }); + + it("stages a new connect although an old pending record cannot be listed", async () => { + // A record whose stub no longer deserializes (its Worker was unbound) fails every listing, and + // cannot be deleted without one. Staging must not depend on it, and the sweep must keep retrying + // rather than failing the alarm forever. + const { stub, inDo } = freshUser(); + const before = Date.now(); + const { ticket } = await inDo(async user => { + user.ctx.storage.kv.put(`pendingHandoffs:${"0".repeat(64)}`, null); + expect(() => pendingCount(user)).toThrow(); + const staged = await user.stagePendingConnect(0, fakeAccount(user, "listable").account, "github"); + const alarm = await user.ctx.storage.getAlarm(); + expect(alarm).toBeGreaterThanOrEqual(before + PENDING_HANDOFF_LIFETIME_MS); + await user.alarm(); + expect(await user.ctx.storage.getAlarm()).toBeGreaterThanOrEqual(before + PENDING_HANDOFF_LIFETIME_MS); + return staged; + }); + + expect(await redeem(stub, ticket)).toBe("ok"); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)?.vendorId).toBe("github"); + }); + }); + + it("drops an expired reconnect stage without touching the live account", async () => { + const { inDo } = freshUser(); + await inDo(async user => { + user.storage.nextAccountId.put(1); + user.storage.connectedAccounts.put({ + id: 0, account: fakeAccount(user, "live").account, vendorId: "github", + description: { displayName: "live" }, + }); + await user.stagePendingRestore(0, STAGE_ID); + expirePending(user); + await user.alarm(); + expect(pendingCount(user)).toBe(0); + expect(await fakeAccount(user, "live").calls()).toEqual([]); + expect(user.storage.connectedAccounts.get(0)?.description).toEqual({ displayName: "live" }); + }); + }); + + it("derives the target origin from PUBLIC_BASE_URL only, failing closed without it", () => { + expect(handoffTargetOrigin({ PUBLIC_BASE_URL: `${TARGET}/some/path` } as Cloudflare.Env)) + .toBe(TARGET); + expect(() => handoffTargetOrigin({} as Cloudflare.Env)).toThrow("PUBLIC_BASE_URL"); + }); + + it("stages through the gatekeeper-facing callback exactly as a connector calls it", async () => { + const { stub, inDo } = freshUser(); + const handoff = await inDo(async user => { + user.storage.nextAccountId.put(1); + const callback = user.ctx.exports.TestConnectCallback({ + props: { userId: user.ctx.id.toString(), accountId: 0, vendorId: "github" }, + }); + const staged = await callback.complete(fakeAccount(user, "via-callback").account); + expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); + return staged; + }); + expect(handoff.targetOrigin).toBe(TARGET); + + await stub.completeConnectHandoff(handoff.ticket); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)?.vendorId).toBe("github"); + // A reconnect finishing on the same callback stages a restore, not a second account. + const callback = user.ctx.exports.TestConnectCallback({ + props: { userId: user.ctx.id.toString(), accountId: 0, vendorId: "github" }, + }); + await callback.reconnectComplete(STAGE_ID); + expect(pendingCount(user)).toBe(1); + expect(user.storage.nextAccountId.get()).toBe(1); + }); + }); +}); diff --git a/packages/workshop-backend/__tests__/pending-login.test.ts b/packages/workshop-backend/__tests__/pending-login.test.ts new file mode 100644 index 0000000000..9fd6b56847 --- /dev/null +++ b/packages/workshop-backend/__tests__/pending-login.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { env } from "cloudflare:workers"; +import { runInDurableObject } from "cloudflare:test"; +import { + LOGIN_PENDING_LIFETIME_MS, type LoginConnectCallbackImpl, type PendingLogin, +} from "../src/auth/login-flow.js"; +import type { UserDurableObject } from "../src/user.js"; +import { hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS } from "../src/connect-handoff.js"; +import type { FakeGatekeeperAccount } from "./test-worker.js"; + +declare module "cloudflare:workers" { + interface ProvidedEnv { + TEST_PENDING_LOGIN: DurableObjectNamespace; + TEST_USER: DurableObjectNamespace; + } +} + +// What a test reaches into the user DO for: the collections behind the callback's effects. +type UserInternals = UserDurableObject & { + storage: { + connectedAccounts: { get(id: number): Record | undefined; put(record: unknown): void }; + pendingHandoffs: { list(): Iterable> }; + nextAccountId: { put(n: number): void }; + }; + ctx: DurableObjectState & { + exports: { + FakeGatekeeperAccount(options: { props: { name: string } }): Fetcher; + TestLoginCallback(options: { props: { pendingId: string; vendorId: string } }) + : Fetcher; + }; + }; +}; + +let counter = 0; +const fresh = () => env.TEST_PENDING_LOGIN.getByName(`pending-login-${++counter}`); + +// Claims over the stub the way the browser does, reporting the outcome as a value (a native RPC +// promise left to `.rejects` is also flagged as an unhandled rejection by the pool). +async function claim(stub: DurableObjectStub, ticket: string): Promise { + try { + const token = await stub.claim(ticket); + return token === null ? "null" : `token:${token}`; + } catch (err) { + return `error:${(err as Error).message}`; + } +} + +describe("PendingLogin", () => { + it("releases the token once, and only to the matching ticket", async () => { + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + await runInDurableObject(stub, async (instance: PendingLogin) => { + expect(await instance.ctx.storage.getAlarm()).toBeGreaterThan(Date.now()); + expect(await instance.ctx.storage.getAlarm()).toBeLessThanOrEqual( + Date.now() + PENDING_HANDOFF_LIFETIME_MS); + }); + + // Holding the attempt is not enough: the attacker's own tab never sees the ticket. And a ticket + // for some other attempt (the window hears every same-origin broadcast) neither releases the + // token nor spends the result, so the right ticket still can. + const other = fresh(); + await other.deliver("victim@example.com:session", hash); + expect(await claim(other, (await newSecretToken()).secret.toHex())).toBe("null"); + expect(await claim(other, "not-a-ticket")).toBe("null"); + expect(await claim(other, secret.toHex())).toBe("token:victim@example.com:session"); + + expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); + expect(await claim(stub, secret.toHex())) + .toBe("error:This sign-in attempt has expired. Please try again."); + await runInDurableObject(stub, async (instance: PendingLogin) => { + expect(await instance.ctx.storage.getAlarm()).toBeNull(); + expect([...instance.ctx.storage.kv.list()]).toEqual([]); + }); + }); + + it("answers null to a foreign ticket before the result is delivered", async () => { + // The window hears every same-origin broadcast, so another window's ticket can arrive while the + // user is still at the provider's consent screen. It is not this attempt's, so it must neither + // release anything nor settle the attempt as expired; the attempt keeps waiting. + const stub = fresh(); + await stub.begin(); + await runInDurableObject(stub, async (instance: PendingLogin) => { + // The wait for the gatekeeper outlives a delivered result, which has the shorter lifetime. + expect(await instance.ctx.storage.getAlarm()).toBeGreaterThan( + Date.now() + PENDING_HANDOFF_LIFETIME_MS); + expect(await instance.ctx.storage.getAlarm()).toBeLessThanOrEqual( + Date.now() + LOGIN_PENDING_LIFETIME_MS); + }); + expect(await claim(stub, (await newSecretToken()).secret.toHex())).toBe("null"); + expect(await claim(stub, "not-a-ticket")).toBe("null"); + + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); + }); + + it("expires an attempt that never delivered", async () => { + const stub = fresh(); + await stub.begin(); + await runInDurableObject(stub, async (instance: PendingLogin) => { + const [[key, stored]] = [...instance.ctx.storage.kv.list()] as [string, { expiresAt: number }][]; + instance.ctx.storage.kv.put(key, { ...stored, expiresAt: Date.now() - 1 }); + }); + + expect(await claim(stub, (await newSecretToken()).secret.toHex())) + .toBe("error:This sign-in attempt has expired. Please try again."); + }); + + it("reports the gatekeeper's failure to whoever claims", async () => { + const stub = fresh(); + await stub.fail("This account has no verified email, so it can't be used to sign in."); + + expect(await claim(stub, "f".repeat(64))) + .toBe("error:This account has no verified email, so it can't be used to sign in."); + expect(await claim(stub, "f".repeat(64))) + .toBe("error:This sign-in attempt has expired. Please try again."); + }); + + it("wipes an unclaimed token from the alarm", async () => { + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + + await runInDurableObject(stub, (instance: PendingLogin) => instance.alarm()); + expect(await claim(stub, secret.toHex())) + .toBe("error:This sign-in attempt has expired. Please try again."); + }); + + it("refuses a claim past the lifetime even if the alarm has not fired", async () => { + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + // Age the stored result without running the alarm: validity must not depend on it. + await runInDurableObject(stub, async (instance: PendingLogin) => { + const [[key, stored]] = [...instance.ctx.storage.kv.list()] as [string, { expiresAt: number }][]; + expect(stored.expiresAt).toBeGreaterThan(Date.now()); + instance.ctx.storage.kv.put(key, { ...stored, expiresAt: Date.now() - 1 }); + }); + + expect(await claim(stub, secret.toHex())) + .toBe("error:This sign-in attempt has expired. Please try again."); + }); + + it("keeps the account link after the result is claimed or swept", async () => { + // The link is what lets the gatekeeper's callback reach the linked account for the rest of its + // life, so neither redeeming the sign-in nor the expiry sweep may take it with the result. + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.link("user-do-id", 3); + await stub.deliver("alice@example.com:session", hash); + expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); + expect(await stub.getLink()).toEqual({ userId: "user-do-id", accountId: 3 }); + + await stub.deliver("alice@example.com:again", hash); + await runInDurableObject(stub, (instance: PendingLogin) => instance.alarm()); + expect(await claim(stub, secret.toHex())) + .toBe("error:This sign-in attempt has expired. Please try again."); + expect(await stub.getLink()).toEqual({ userId: "user-do-id", accountId: 3 }); + }); + + it("stores only the ticket's hash", async () => { + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + + await runInDurableObject(stub, async (instance: PendingLogin) => { + const stored = JSON.stringify([...instance.ctx.storage.kv.list()]); + expect(stored).not.toContain(secret.toHex()); + expect(stored).toContain(await hashSecret(secret)); + }); + }); +}); + +describe("LoginConnectCallbackImpl", () => { + const STAGE_ID = "5".repeat(64); + + // The callback as the gatekeeper holds it, minted inside the user DO (whose `ctx.exports` is the + // only way to reach a callback entrypoint from a test). + function callbackFor(user: UserInternals, pendingId: string) { + return user.ctx.exports.TestLoginCallback({ props: { pendingId, vendorId: "cloudflare" } }); + } + + it("routes a linked account's reconnect and expiry to its user", async () => { + // Cloudflare sign-in persists a connected account whose callback is this object for life, so + // the account must be able to reconnect and be marked expired like one connected the usual way. + const pending = fresh(); + const userStub = env.TEST_USER.getByName("login-callback-linked"); + await pending.link(userStub.id.toString(), 0); + const pendingId = pending.id.toString(); + await runInDurableObject(userStub, async (instance: UserDurableObject) => { + const user = instance as UserInternals; + user.storage.nextAccountId.put(1); + user.storage.connectedAccounts.put({ + id: 0, account: user.ctx.exports.FakeGatekeeperAccount({ props: { name: "cf" } }), + vendorId: "cloudflare", description: { displayName: "cf" }, + }); + const callback = callbackFor(user, pendingId); + + const handoff = await callback.reconnectComplete(STAGE_ID, new Date("2027-01-01")); + expect(handoff.ticket).toMatch(/^[0-9a-f]{64}$/); + expect([...user.storage.pendingHandoffs.list()]).toMatchObject([ + { kind: "restore", accountId: 0, stageId: STAGE_ID }, + ]); + expect(user.storage.connectedAccounts.get(0)?.credentialsExpired).toBeUndefined(); + + await callback.credentialsExpired(); + expect(user.storage.connectedAccounts.get(0)?.credentialsExpired).toBe(true); + await callback.credentialsRestored(new Date("2027-02-01")); + expect(user.storage.connectedAccounts.get(0)).toMatchObject({ + credentialsExpired: false, credentialExpiresAt: new Date("2027-02-01"), + }); + }); + }); + + it("has nothing to reconnect or update for a transient sign-in grant", async () => { + const pendingId = fresh().id.toString(); + const userStub = env.TEST_USER.getByName("login-callback-unlinked"); + await runInDurableObject(userStub, async (instance: UserDurableObject) => { + const user = instance as UserInternals; + const callback = callbackFor(user, pendingId); + let outcome = "ok"; + try { + await callback.reconnectComplete(STAGE_ID); + } catch (err) { + outcome = (err as Error).message; + } + expect(outcome).toBe("Sign-in flows cannot be reconnected."); + await callback.credentialsExpired(); + expect([...user.storage.pendingHandoffs.list()]).toEqual([]); + }); + }); +}); diff --git a/packages/workshop-backend/__tests__/test-worker.ts b/packages/workshop-backend/__tests__/test-worker.ts new file mode 100644 index 0000000000..dd9a35bc0f --- /dev/null +++ b/packages/workshop-backend/__tests__/test-worker.ts @@ -0,0 +1,54 @@ +// The Worker the unit suites run inside: the production Worker's exports (so `ctx.exports` resolves +// the real Durable Objects and callbacks) plus test-only entrypoints that stand in for other Workers. + +import { WorkerEntrypoint } from "cloudflare:workers"; +import type { AccountDescription } from "@gadgets/workshop-shared/gatekeeper"; +import { GatekeeperConnectCallbackImpl } from "../src/user.js"; +import { LoginConnectCallbackImpl } from "../src/auth/login-flow.js"; + +export * from "../src/server.js"; +export { default } from "../src/server.js"; +/** + * The Workshop's connect callback, reachable through `ctx.exports`: the pool derives those from this + * module's own declarations, so an entrypoint a test reaches that way has to be named here rather + * than covered by the `export *`. + */ +export class TestConnectCallback extends GatekeeperConnectCallbackImpl {} +/** The sign-in callback, reachable the same way. */ +export class TestLoginCallback extends LoginConnectCallbackImpl {} + +/** What each FakeGatekeeperAccount has been asked to do, by its `name` prop. */ +const accountCalls = new Map(); + +/** + * A gatekeeper account as the Workshop sees one: a persistent stub it can store and call back into. + * Records calls by `props.name` so a test can ask any instance what happened (`calls()`); with + * `failRevoke` / `failDescribe`, that method rejects after being recorded. + */ +export class FakeGatekeeperAccount + extends WorkerEntrypoint { + #record(call: string) { + const calls = accountCalls.get(this.ctx.props.name) ?? []; + calls.push(call); + accountCalls.set(this.ctx.props.name, calls); + } + + async describe(): Promise { + this.#record("describe"); + if (this.ctx.props.failDescribe) throw new Error("describe failed"); + return { displayName: this.ctx.props.name, uniqueName: this.ctx.props.name }; + } + + async revoke(): Promise { + this.#record("revoke"); + if (this.ctx.props.failRevoke) throw new Error("revoke failed"); + } + + async commitReconnect(stageId: string): Promise { + this.#record(`commitReconnect(${stageId})`); + } + + async calls(): Promise { + return accountCalls.get(this.ctx.props.name) ?? []; + } +} diff --git a/packages/workshop-backend/src/auth/login-flow.ts b/packages/workshop-backend/src/auth/login-flow.ts index 4030498b46..e947aaad7c 100644 --- a/packages/workshop-backend/src/auth/login-flow.ts +++ b/packages/workshop-backend/src/auth/login-flow.ts @@ -8,74 +8,136 @@ // 1. PublicApi.startGatekeeperLogin(vendorId) creates a PendingLogin DO (keyed by a random DO id), // hands the gatekeeper a LoginConnectCallbackImpl, and returns {url, attempt}, where `attempt` // is an RpcStub wrapping the DO (so the client awaits via a capability, never a guessable id). -// 2. The browser opens `url` (the gatekeeper's self-closing OAuth popup) and calls -// `attempt.wait()`, which blocks on the PendingLogin DO. +// 2. The browser opens `url` as a popup, keeping itself as the popup's opener. // 3. When the gatekeeper finishes, it calls LoginConnectCallbackImpl.complete(user). We read the // verified email, resolve/create the email-keyed user DO, mint a session, and deliver the token -// to the PendingLogin DO, which resolves the awaiting RPC. +// to the PendingLogin DO under the hash of a fresh handoff ticket, which complete() returns for +// the gatekeeper's final page to post to its opener (see connect-handoff.ts). +// 4. The opener calls `attempt.claim(ticket)`, and the PendingLogin DO releases the token only for +// a matching ticket; a ticket for some other attempt is answered with null and changes nothing, +// whether it arrives before or after this attempt's result has been delivered. +// +// The sign-in URL is a bearer capability, so step 4 is what binds the session to the browser that +// started the attempt: whoever holds `attempt` but never receives the ticket — an attacker who +// phished a victim into finishing the flow — gets nothing, and the unclaimed token expires. // // Sign-in only requests minimal scopes and the gatekeeper grant is transient (it self-destructs // shortly after we read the email) — so login does NOT create a persistent connected account. // Capability access (repos, docs, billing) is granted later when the user explicitly connects the // gatekeeper, which requests the full scopes and persists the connection. +// +// Cloudflare is the exception: signing in also links the account for billing, and the gatekeeper +// keeps this callback for that account's lifetime. The PendingLogin DO therefore also records which +// user and account the sign-in produced (`link`), outliving the login result, so expiry notices and +// reconnects for the account reach its user DO. import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; -import { GatekeeperConnectCallback, GatekeeperUser } from "@gadgets/workshop-shared/gatekeeper"; +import { ConnectHandoff, GatekeeperConnectCallback, GatekeeperUser } from "@gadgets/workshop-shared/gatekeeper"; import { createWorkshopLogger } from "../observability"; -import { CLOUDFLARE_VENDOR_ID } from "../user.js"; +import { CLOUDFLARE_VENDOR_ID, type UserDurableObject } from "../user.js"; import { readAdminConfig } from "../admin-config.js"; +import { + handoffTargetOrigin, hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS, +} from "../connect-handoff.js"; const logger = createWorkshopLogger("workshop.auth"); -type PendingResult = { token: string } | { error: string }; +// `pending` is the attempt as started, before the OAuth callback has delivered anything: it lets +// claim() tell "not this attempt's ticket, yet" from an expired or never-started attempt. +type PendingOutcome = { pending: true } | { token: string; ticketHash: string } | { error: string }; +// `expiresAt` bounds the result absolutely: the alarm wipes it too, but claim() must not depend on +// the alarm having fired on time. +type PendingResult = PendingOutcome & { expiresAt: number }; /** - * Bridges a login result from the (separate) OAuth-callback invocation back to the waiting browser. - * - * This DO holds no durable storage: a login normally completes within seconds, and the in-flight - * awaitResult() request keeps the DO alive so the in-memory waiter is reachable when deliver()/fail() - * fire. If the attempt is abandoned, the client disposes the awaiting RPC (the `attempt` stub) and - * the DO is simply evicted — no alarm or cleanup needed. + * How long a started attempt waits for the gatekeeper to deliver, matching the gatekeepers' own + * connect-nonce lifetime. The shorter PENDING_HANDOFF_LIFETIME_MS is for a delivered result and + * would expire a user who is still at the provider's consent screen. + */ +export const LOGIN_PENDING_LIFETIME_MS = 10 * 60 * 1000; + +// The connected account a sign-in persisted, by the user DO that owns it (see `PendingLogin.link`). +type AccountLink = { userId: string; accountId: number }; + +const RESULT_KEY = "result"; +const LINK_KEY = "link"; +const EXPIRED_MESSAGE = "This sign-in attempt has expired. Please try again."; + +/** + * Bridges a login result from the (separate) OAuth-callback invocation back to the browser that + * started the attempt. Everything is written to storage, since nothing keeps this DO in memory + * between the calls: begin() marks the attempt as started (for LOGIN_PENDING_LIFETIME_MS), so that + * a foreign ticket the browser hears in the meantime is answered with null rather than mistaken for + * an expired attempt; deliver()/fail() replace the marker with the result, which lives for + * PENDING_HANDOFF_LIFETIME_MS at most. An alarm then wipes whatever is left unclaimed. An account + * link (`link`) is kept for as long as the account exists. */ export class PendingLogin extends DurableObject { - // Awaiters from in-flight awaitResult() calls, resolved/rejected when the result arrives. - #waiters: { resolve: (token: string) => void; reject: (err: Error) => void }[] = []; - // Stash for the rare case deliver()/fail() arrives before awaitResult() registers a waiter. - #result?: PendingResult; - - /** Block until the login completes (or fails). */ - async awaitResult(): Promise { - if (this.#result) { - const result = this.#result; - this.#result = undefined; // one-time use - if ("token" in result) return result.token; - throw new Error(result.error); - } - return await new Promise((resolve, reject) => { - this.#waiters.push({ resolve, reject }); - }); + /** Called by PublicApi.startGatekeeperLogin before the gatekeeper flow starts. */ + async begin(): Promise { + await this.#store({ pending: true }, LOGIN_PENDING_LIFETIME_MS); + } + + /** Called by LoginConnectCallbackImpl on success, with the hash of the ticket that may claim it. */ + async deliver(token: string, ticketHash: string): Promise { + await this.#store({ token, ticketHash }); + } + + /** Called by LoginConnectCallbackImpl when the sign-in cannot complete; claim() reports `reason`. */ + async fail(reason: string): Promise { + await this.#store({ error: reason }); + } + + async #store(result: PendingOutcome, lifetimeMs = PENDING_HANDOFF_LIFETIME_MS): Promise { + const expiresAt = Date.now() + lifetimeMs; + this.ctx.storage.kv.put(RESULT_KEY, { ...result, expiresAt }); + await this.ctx.storage.setAlarm(expiresAt); } /** - * Called by LoginConnectCallbackImpl on success: resolve the awaiter (or stash the token if none - * is waiting yet). + * Records the connected account this sign-in persisted, so the callback the gatekeeper holds for + * it can reach the account's user DO. Independent of the login result: a sign-in whose ticket is + * never claimed still linked the (owner's own) account. */ - async deliver(token: string): Promise { - if (this.#waiters.length > 0) { - for (const w of this.#waiters) w.resolve(token); - this.#waiters = []; - } else { - this.#result = { token }; - } + async link(userId: string, accountId: number): Promise { + this.ctx.storage.kv.put(LINK_KEY, { userId, accountId }); } - async fail(reason: string): Promise { - if (this.#waiters.length > 0) { - for (const w of this.#waiters) w.reject(new Error(reason)); - this.#waiters = []; - } else { - this.#result = { error: reason }; + async getLink(): Promise { + return this.ctx.storage.kv.get(LINK_KEY) ?? null; + } + + /** + * Release the token to the holder of the matching ticket. A ticket that is not this attempt's + * (the window may hear every same-origin broadcast) yields null and leaves the result — or the + * still-pending attempt, whose ticket hash is not known yet — in place. Single use otherwise: the + * ticket is hashed before the read, so the read, check and removal of a matching result happen in + * one step under the input gate and a repeat gets no second try. + */ + async claim(ticket: string): Promise { + const hash = /^[0-9a-f]{64}$/.test(ticket) ? await hashSecret(Uint8Array.fromHex(ticket)) : null; + const result = this.ctx.storage.kv.get(RESULT_KEY); + if (!result || Date.now() >= result.expiresAt) { + await this.#clear(); + throw new Error(EXPIRED_MESSAGE); + } + if ("pending" in result) return null; + if ("error" in result) { + await this.#clear(); + throw new Error(result.error); } + if (hash !== result.ticketHash) return null; + await this.#clear(); + return result.token; + } + + async #clear(): Promise { + this.ctx.storage.kv.delete(RESULT_KEY); + await this.ctx.storage.deleteAlarm(); + } + + async alarm(): Promise { + this.ctx.storage.kv.delete(RESULT_KEY); } } @@ -89,7 +151,19 @@ export class LoginConnectCallbackImpl return this.ctx.exports.PendingLogin.get(id); } - async complete(account: Fetcher, expiresAt?: Date): Promise { + /** + * Mints the session and parks it in the PendingLogin DO under a fresh ticket's hash; returns the + * handoff whose ticket `LoginAttempt.claim()` must present to receive it. + */ + async complete(account: Fetcher, expiresAt?: Date): Promise { + const targetOrigin = handoffTargetOrigin(this.env); + const { secret, hash } = await newSecretToken(); + await this.#deliver(account, expiresAt, hash); + return { targetOrigin, ticket: secret.toHex() }; + } + + async #deliver(account: Fetcher, expiresAt: Date | undefined, + ticketHash: string): Promise { const loginLogger = logger.with({ operation: "gatekeeper.login", vendorId: this.ctx.props.vendorId, @@ -124,11 +198,13 @@ export class LoginConnectCallbackImpl // requested full (non-transient) scopes, so persist the grant as a connected account before // handing back the session. Other providers use minimal, transient sign-in grants (no persist). if (this.ctx.props.vendorId === CLOUDFLARE_VENDOR_ID) { - await userStub.linkConnectedAccountFromLogin(account, this.ctx.props.vendorId, expiresAt); + const accountId = await userStub.linkConnectedAccountFromLogin( + account, this.ctx.props.vendorId, expiresAt); + await pending.link(userStub.id.toString(), accountId); } // Session tokens are ":"; PublicApi.authenticate() routes via idFromName of // the first part. The user DO is keyed by email, so the prefix must be the email. - await pending.deliver(`${email}:${secret}`); + await pending.deliver(`${email}:${secret}`, ticketHash); loginLogger.info("gatekeeper login finished", { event: "gatekeeper.login.finished", outcome: "ok", }); @@ -143,13 +219,28 @@ export class LoginConnectCallbackImpl } } - /** - * No-ops: for transient sign-in grants there's nothing persisted to update. For the Cloudflare - * billing connection (persisted on login) these would ideally flip the account's credential flag, - * but the callback doesn't carry the user/account identity (it's only learned in complete()). The - * billing path degrades gracefully regardless — getUsableAccessToken() returns null on expiry and - * the user falls back to the free tier / a reconnect prompt. - */ - async credentialsExpired(): Promise {} - async credentialsRestored(_expiresAt?: Date): Promise {} + // The user DO and account id a sign-in linked (Cloudflare), or null for a transient sign-in + // grant, which persists nothing there is to update. + async #linked(): Promise<{ user: DurableObjectStub; accountId: number } | null> { + const link = await this.#pending().getLink(); + if (!link) return null; + const id = this.ctx.exports.UserDurableObject.idFromString(link.userId); + return { user: this.ctx.exports.UserDurableObject.get(id), accountId: link.accountId }; + } + + async credentialsExpired(): Promise { + const linked = await this.#linked(); + if (linked) await linked.user.markCredentialsExpired(linked.accountId); + } + + async credentialsRestored(expiresAt?: Date): Promise { + const linked = await this.#linked(); + if (linked) await linked.user.markCredentialsRestored(linked.accountId, expiresAt); + } + + async reconnectComplete(stageId: string, expiresAt?: Date): Promise { + const linked = await this.#linked(); + if (!linked) throw new Error("Sign-in flows cannot be reconnected."); + return linked.user.stagePendingRestore(linked.accountId, stageId, expiresAt); + } } diff --git a/packages/workshop-backend/src/connect-handoff.ts b/packages/workshop-backend/src/connect-handoff.ts new file mode 100644 index 0000000000..0acb0db7d5 --- /dev/null +++ b/packages/workshop-backend/src/connect-handoff.ts @@ -0,0 +1,40 @@ +// The connect handoff: how a finished gatekeeper connect flow is bound to the browser that started +// it. A connect URL is a bearer capability, so the gatekeeper's final page delivers a single-use +// ticket to the Workshop — over a same-origin BroadcastChannel for a connect popup (which the +// Workshop disowns before navigating, so the provider never holds its window), or by postMessage to +// its opener for sign-in — and the Workshop activates the staged grant only when that ticket is +// redeemed over the initiating user's own session (UserDurableObject.completeConnectHandoff). + +/** + * How long a staged connect / reconnect waits for its ticket. The handoff page delivers the ticket + * the instant it loads, so anything not redeemed within this window was opened somewhere the + * Workshop could not reach, and the staged grant is dropped (and, for a connect, revoked). + */ +export const PENDING_HANDOFF_LIFETIME_MS = 2 * 60 * 1000; + +/** + * The Workshop origin the handoff page must post its ticket to. Comes from deployment configuration + * only: a request's `Origin` header or anything the client asserts could route the ticket to an + * attacker-controlled opener, so neither is consulted. Fails closed when unset. + */ +export function handoffTargetOrigin(env: Cloudflare.Env): string { + if (!env.PUBLIC_BASE_URL) { + throw new Error("PUBLIC_BASE_URL is not configured, so account connections cannot complete."); + } + return new URL(env.PUBLIC_BASE_URL).origin; +} + +/** + * Mint a 256-bit bearer secret plus the SHA-256 (hex) under which it is stored, so a leaked storage + * dump reveals nothing redeemable. Shared by session tokens and handoff tickets. + */ +export async function newSecretToken(): Promise<{ secret: Uint8Array; hash: string }> { + let secret = new Uint8Array(32); + crypto.getRandomValues(secret); + return { secret, hash: await hashSecret(secret) }; +} + +/** SHA-256 hex of a secret, the form in which secrets are looked up at rest. */ +export async function hashSecret(secret: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest("SHA-256", secret)).toHex(); +} diff --git a/packages/workshop-backend/src/observability.ts b/packages/workshop-backend/src/observability.ts index 675a983562..32fb07a450 100644 --- a/packages/workshop-backend/src/observability.ts +++ b/packages/workshop-backend/src/observability.ts @@ -18,6 +18,7 @@ export type WorkshopObservabilityFields = { failureCount: number; gadgetId: string; gatekeeperId: number | string; + handoffKind: "connect" | "restore"; hookId: number; logBytes: number; modelId: string; diff --git a/packages/workshop-backend/src/server.ts b/packages/workshop-backend/src/server.ts index 042b8bcd74..cf05d28a2d 100644 --- a/packages/workshop-backend/src/server.ts +++ b/packages/workshop-backend/src/server.ts @@ -318,6 +318,10 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { return this.#user.connectAccount(vendorId, resourceUrlPatterns); } + completeConnectHandoff(ticket: string): Promise { + return this.#user.completeConnectHandoff(ticket); + } + ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}> { return this.#user.ensureAccountResources(accountId, resourceUrlPatterns); } @@ -617,18 +621,17 @@ async function serveBlueprintScreenshot(env: Env, blueprintId: string): Promise< }); } -// Returned by startGatekeeperLogin(). Wraps the PendingLogin DO so the client awaits the login +// Returned by startGatekeeperLogin(). Wraps the PendingLogin DO so the client redeems the login // result through a capability (this stub) rather than a guessable id — no login id is ever exposed -// to the client. Disposing the stub (e.g. when the pop-up closes or the component unmounts) cancels -// the in-flight wait and lets the DO be evicted. +// to the client. The stub alone is not enough: claim() also needs the ticket the popup posts back. @validateRpc() class LoginAttemptImpl extends RpcTarget implements LoginAttempt { constructor(private pending: DurableObjectStub) { super(); } - async wait(): Promise { - return await this.pending.awaitResult(); + async claim(ticket: string): Promise { + return await this.pending.claim(ticket); } } @@ -662,6 +665,9 @@ class PublicApiImpl extends RpcTarget implements PublicApi { // invocation. The client never sees its id — we hand back an `attempt` stub instead. const pendingId = this.ctx.exports.PendingLogin.newUniqueId(); const pending = this.ctx.exports.PendingLogin.get(pendingId); + // Mark the attempt as started before the gatekeeper can deliver to it, so a foreign ticket the + // browser hears first is answered with null instead of expiring an attempt that is still running. + await pending.begin(); const callback = this.ctx.exports.LoginConnectCallbackImpl( { props: { pendingId: pendingId.toString(), vendorId } }); // For most providers, sign-in needs only minimal scopes to verify the user's email (the grant is diff --git a/packages/workshop-backend/src/user.ts b/packages/workshop-backend/src/user.ts index 5ad4a239f7..5c613db9b2 100644 --- a/packages/workshop-backend/src/user.ts +++ b/packages/workshop-backend/src/user.ts @@ -1,6 +1,6 @@ import { RpcStub } from "capnweb"; import { GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, SUGGESTED_MODELS, CollaboratorRole, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, GadgetMetadata, BlueprintMetadata, BlueprintLibrarySummary, BlueprintSource, BlueprintUserSummary, BLUEPRINT_SCREENSHOT_R2_PREFIX, GatekeeperVendorInfo, BlueprintOutput, OutputSummary, WorkpieceId, ListOutputsResult, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api'; -import { Gatekeeper, GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor, AccountDescription, VendorDescription, GatekeeperConnectCallback, SupportedResource, ResourceConfiguratorFrame, AppUiContext, GatekeeperUiFrame } from "@gadgets/workshop-shared/gatekeeper"; +import { Gatekeeper, GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor, AccountDescription, VendorDescription, GatekeeperConnectCallback, ConnectHandoff, SupportedResource, ResourceConfiguratorFrame, AppUiContext, GatekeeperUiFrame } from "@gadgets/workshop-shared/gatekeeper"; import { shouldAutoProvisionAccount, ambientGatekeeperMode } from "./provisioning-policy.js"; import { CloudflareGatekeeperUser } from "@gadgets/workshop-shared/cloudflare-gatekeeper"; import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; @@ -12,6 +12,7 @@ import type { AdminSettings } from "./admin-settings.js"; import { isReservedBlueprintKey, readBlueprintKvRecord } from "./blueprint-archive.js"; import { filterEnabledResources, isResourceDisabled, readAdminConfig } from "./admin-config.js"; import { buildGatekeeperVendorMap } from "./auth/auth-vendors.js"; +import { handoffTargetOrigin, hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS } from "./connect-handoff.js"; const logger = createWorkshopLogger("workshop.user"); @@ -32,6 +33,22 @@ type ConnectedAccountRecord = { autoProvisioned?: boolean; }; +// A connect ("connect") or reconnect/ensureResources ("restore") flow that a gatekeeper has finished +// but the user's browser has not yet confirmed (see connect-handoff.ts). Keyed by the SHA-256 of the +// ticket; single-use, and swept by alarm() once `expiresAt` passes. +type PendingHandoffRecord = { + ticketHash: string; + kind: "connect" | "restore"; + accountId: number; + expiresAt: Date; + credentialExpiresAt?: Date; + // The staged account, present for `kind: "connect"` only; becomes the ConnectedAccountRecord. + connect?: Pick; + // The gatekeeper's id for the staged credentials, present for `kind: "restore"` only; passed back + // in commitReconnect() so this ticket can activate no other stage's credentials. + stageId?: string; +}; + /** * Metadata about an auto-provisioned account that provides an agent singleton and/or a management UI. * Returned to the overseer (ambient capsules / catalog) and the management-UI listing. @@ -169,6 +186,9 @@ function makeUserStorage(storage: DurableObjectStorage) { sessions: collection()({ primaryKey: "tokenId", }), + pendingHandoffs: collection()({ + primaryKey: "ticketHash", + }), blueprints: collection()({ primaryKey: "id", }), @@ -342,13 +362,9 @@ export class UserDurableObject extends DurableObject { } async #newSessionToken(): Promise { - let sessionToken = new Uint8Array(32); - crypto.getRandomValues(sessionToken); - - let tokenId = new Uint8Array(await crypto.subtle.digest('SHA-256', sessionToken)).toHex(); + let { secret, hash: tokenId } = await newSecretToken(); this.storage.sessions.put({ tokenId, created: new Date() }); - - return sessionToken.toBase64(); + return secret.toBase64(); } async login(passwordHash: Uint8Array): Promise { @@ -1558,9 +1574,11 @@ export class UserDurableObject extends DurableObject { * links the account for AI Gateway billing: the login callback resolves this user by verified * email, then calls here to store the resulting grant. That grant covers billing only: sign-in * requests no gadget-facing resources, so any later resource access is authorized separately. + * Returns the id of the connected account the grant now backs, so the login callback — which the + * gatekeeper keeps for that account's lifetime — can find it again. */ async linkConnectedAccountFromLogin( - account: Fetcher, vendorId: string, expiresAt?: Date): Promise { + account: Fetcher, vendorId: string, expiresAt?: Date): Promise { let description = await account.describe(); let uniqueName = description.uniqueName; @@ -1587,7 +1605,7 @@ export class UserDurableObject extends DurableObject { existing.credentialExpiresAt = expiresAt; existing.credentialsExpired = false; this.storage.connectedAccounts.put(existing); - return; + return existing.id; } } @@ -1600,6 +1618,7 @@ export class UserDurableObject extends DurableObject { vendorId, credentialExpiresAt: expiresAt, }); + return id; } // Find an existing connected account for the given vendor + identity (uniqueName), excluding @@ -1656,11 +1675,168 @@ export class UserDurableObject extends DurableObject { let record = this.storage.connectedAccounts.get(accountId); if (!record) throw new Error("No such account."); - // Re-fetch description since the user may have re-authed with different info. - record.description = await record.account.describe(); record.credentialsExpired = false; record.credentialExpiresAt = expiresAt; this.storage.connectedAccounts.put(record); + + // Re-fetch the description since the user may have re-authed with different info. Best-effort: + // the credentials are live either way, and a record still showing as expired over a failed + // describe() would send the user back through a reconnect that changes nothing. + try { + record.description = await record.account.describe(); + this.storage.connectedAccounts.put(record); + } catch (err) { + logger.warn("failed to refresh the description of a restored account", { + event: "account.describe.refresh.failed", vendorId: record.vendorId, accountId, error: err, + }); + } + } + + // --- Connect handoff (see connect-handoff.ts) --- + + // Store a finished-but-unconfirmed flow and hand back the ticket its page must post to the + // Workshop window. Only the ticket's hash is kept, and only in this user's DO, so the ticket is + // redeemable by nobody else (completeConnectHandoff looks it up in the caller's own DO). + async #stagePendingHandoff( + record: Omit): Promise { + let targetOrigin = handoffTargetOrigin(this.env); + let { secret, hash: ticketHash } = await newSecretToken(); + let expiresAt = new Date(Date.now() + PENDING_HANDOFF_LIFETIME_MS); + this.storage.pendingHandoffs.put({ ...record, ticketHash, expiresAt }); + await this.#armHandoffSweep(); + return { targetOrigin, ticket: secret.toHex() }; + } + + /** A gatekeeper finished a connect flow for a reserved account id; stage it until confirmed. */ + async stagePendingConnect(accountId: number, account: Fetcher, vendorId: string, + credentialExpiresAt?: Date): Promise { + let description = await account.describe(); + return this.#stagePendingHandoff({ + kind: "connect", accountId, credentialExpiresAt, connect: { account, description, vendorId }, + }); + } + + /** + * A gatekeeper finished a reconnect/ensureResources flow; its credentials stay staged there under + * `stageId`, which commitReconnect() names so the ticket activates exactly those credentials. + */ + stagePendingRestore(accountId: number, stageId: string, credentialExpiresAt?: Date) + : Promise { + return this.#stagePendingHandoff({ kind: "restore", accountId, stageId, credentialExpiresAt }); + } + + /** + * Redeem a ticket delivered to this user's browser. The record is deleted before anything else, so + * a ticket is single-use however the rest goes (DO input gates serialize the read and delete); a + * staged connect the redemption cannot activate is dropped like an unredeemed one, so no grant is + * left reachable in a gatekeeper with nothing to revoke it. + */ + async completeConnectHandoff(ticket: string): Promise { + let record: PendingHandoffRecord | undefined; + if (/^[0-9a-f]{64}$/.test(ticket)) { + let ticketHash = await hashSecret(Uint8Array.fromHex(ticket)); + record = this.storage.pendingHandoffs.get(ticketHash); + if (record) this.storage.pendingHandoffs.delete(ticketHash); + } + if (!record || record.expiresAt.getTime() <= Date.now()) { + if (record) await this.#dropPendingConnect(record); + throw new Error("This connection attempt has expired. Please try again."); + } + + if (record.kind === "connect") { + if (!record.connect) throw new Error("Corrupt pending connection."); + try { + await this.putConnectedAccount({ + id: record.accountId, ...record.connect, credentialExpiresAt: record.credentialExpiresAt, + }); + } catch (err) { + await this.#dropPendingConnect(record); + throw err; + } + logger.info("account connected", { + event: "account.connect.completed", vendorId: record.connect.vendorId, + accountId: record.accountId, + }); + } else { + let account = this.storage.connectedAccounts.get(record.accountId); + if (!account) throw new Error("No such account."); + if (record.stageId === undefined) throw new Error("Corrupt pending reconnect."); + // A failed commit changed nothing live, and the gatekeeper's stage expires on its own. + await account.account.commitReconnect(record.stageId); + await this.markCredentialsRestored(record.accountId, record.credentialExpiresAt); + logger.info("account credentials restored", { + event: "account.reconnect.completed", vendorId: account.vendorId, + accountId: record.accountId, + }); + } + } + + // Drop a pending handoff that will never activate. A staged connect holds a victim's (or just an + // abandoned) grant in a reachable gatekeeper DO, so it is revoked, best-effort. A staged restore + // left nothing live: the gatekeeper's staged credentials stop being committable on their own + // (commitStagedCredentials refuses an expired stage), though they stay stored until the next + // reconnect overwrites them; deleting them from here is a follow-up. + async #dropPendingConnect(pending: PendingHandoffRecord): Promise { + if (pending.kind === "connect" && pending.connect) { + try { + await pending.connect.account.revoke(); + } catch (err) { + logger.warn("failed to revoke unconfirmed connection", { + event: "connect.handoff.revoke.failed", vendorId: pending.connect.vendorId, + accountId: pending.accountId, error: err, + }); + } + } + logger.info("unconfirmed connection dropped", { + event: "connect.handoff.expired", handoffKind: pending.kind, accountId: pending.accountId, + }); + } + + // Arm the alarm for the soonest pending expiry (the alarm is used for nothing else). + async #armHandoffSweep(): Promise { + let next: number | undefined; + try { + for (let pending of this.storage.pendingHandoffs.list()) { + let at = pending.expiresAt.getTime(); + if (next === undefined || at < next) next = at; + } + } catch (err) { + // A record whose stub no longer deserializes (its Worker was unbound) fails the listing, and + // without a keys-only listing it cannot be deleted either. Staging a new connect must not + // depend on listing old ones, so arm a retry instead: one warning per lifetime for this user + // until the Worker is bound again, while the grant the record holds is reachable by nobody. + logger.warn("failed to list pending handoffs", { + event: "connect.handoff.arm.failed", error: err, + }); + next = Date.now() + PENDING_HANDOFF_LIFETIME_MS; + } + if (next === undefined) { + await this.ctx.storage.deleteAlarm(); + } else { + await this.ctx.storage.setAlarm(next); + } + } + + /** Drop pending handoffs whose ticket never came back (see #dropPendingConnect). */ + async alarm(): Promise { + let now = Date.now(); + let expired: PendingHandoffRecord[] = []; + try { + for (let pending of this.storage.pendingHandoffs.list()) { + if (pending.expiresAt.getTime() <= now) expired.push(pending); + } + } catch (err) { + // Same failure mode as #connectedAccountRecords: a stub for a Worker that is no longer bound + // fails to deserialize. Leave the sweep for next time (#armHandoffSweep bounds the retry). + logger.warn("failed to list pending handoffs", { + event: "connect.handoff.sweep.failed", error: err, + }); + } + for (let pending of expired) { + this.storage.pendingHandoffs.delete(pending.ticketHash); + await this.#dropPendingConnect(pending); + } + await this.#armHandoffSweep(); } async getGatekeeperClassFor(accountId: number, url: string) @@ -1739,16 +1915,13 @@ export class GatekeeperConnectCallbackImpl return this.ctx.exports.UserDurableObject.get(userId); } - async complete(account: Fetcher, expiresAt?: Date): Promise { - let userStub = this.#getUserStub(); + complete(account: Fetcher, expiresAt?: Date): Promise { + let {accountId, vendorId} = this.ctx.props; + return this.#getUserStub().stagePendingConnect(accountId, account, vendorId, expiresAt); + } - await userStub.putConnectedAccount({ - id: this.ctx.props.accountId, - account, - description: await account.describe(), - vendorId: this.ctx.props.vendorId, - credentialExpiresAt: expiresAt, - }); + reconnectComplete(stageId: string, expiresAt?: Date): Promise { + return this.#getUserStub().stagePendingRestore(this.ctx.props.accountId, stageId, expiresAt); } async credentialsExpired(): Promise { diff --git a/packages/workshop-backend/vitest.config.ts b/packages/workshop-backend/vitest.config.ts index 8fc7699f33..b2f98764b2 100644 --- a/packages/workshop-backend/vitest.config.ts +++ b/packages/workshop-backend/vitest.config.ts @@ -38,12 +38,17 @@ export default defineConfig({ textModules, capnwebValidate(), cloudflareTest({ - main: './src/server.ts', + // The production Worker plus test-only entrypoints (see __tests__/test-worker.ts). + main: './__tests__/test-worker.ts', miniflare: { compatibilityDate: '2026-09-04', - compatibilityFlags: ['experimental', 'nodejs_compat'], + // `allow_irrevocable_stub_storage` as in wrangler.jsonc: the user DO persists account stubs. + compatibilityFlags: ['experimental', 'nodejs_compat', 'allow_irrevocable_stub_storage'], + bindings: { PUBLIC_BASE_URL: 'https://workshop.example/' }, durableObjects: { TEST_OVERSEER: { className: 'OverseerDurableObject', useSQLite: true }, + TEST_USER: { className: 'UserDurableObject', useSQLite: true }, + TEST_PENDING_LOGIN: { className: 'PendingLogin', useSQLite: true }, }, }, }), diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx index 93b9ddccc0..0748e7b40c 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /* eslint-disable react/react-in-jsx-scope */ -import { act } from 'react' +import { act, type ReactElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcStub } from 'capnweb' @@ -38,6 +38,9 @@ vi.mock('./useAuth', () => ({ })) import BlueprintLandingPage from './BlueprintLandingPage' +import { AuthProvider } from './AuthContext' +import { CONNECT_HANDOFF_MESSAGE_TYPE } from '@gadgets/workshop-shared/gatekeeper' +import { gatekeeperOrigin } from './connectHandoff' (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const originalInnerWidth = window.innerWidth @@ -133,3 +136,68 @@ describe('BlueprintLandingPage model configuration', () => { expect(save.disabled).toBe(false) }) }) + +// A signed-out visitor who logs in on this page does so through the page's own useAuth(): the root +// route stays standalone, with no AuthProvider and so no app-shell ConnectHandoffListener. The page +// must then redeem connect tickets itself, and must not when the shell is already doing so. +describe('BlueprintLandingPage connect handoff', () => { + let root: Root | undefined + let rootContainer: HTMLDivElement | undefined + const completeConnectHandoff = vi.fn<(ticket: string) => Promise>() + + afterEach(() => { + act(() => root?.unmount()) + rootContainer?.remove() + testState.authenticatedApi = null + completeConnectHandoff.mockReset() + }) + + function apiWithHandoff(): RpcStub { + return { + ...(authenticatedApi() as object), + completeConnectHandoff, + whoami: async () => ({ type: 'user', id: 'alice', name: 'Alice' }), + amIAdmin: async () => false, + } as unknown as RpcStub + } + + async function render(element: ReactElement) { + rootContainer = document.createElement('div') + document.body.appendChild(rootContainer) + root = createRoot(rootContainer) + await act(async () => root!.render(element)) + await act(async () => { await Promise.resolve() }) + } + + async function postTicket() { + window.dispatchEvent(new MessageEvent('message', { + data: { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'c'.repeat(64) }, + origin: gatekeeperOrigin(), + })) + await act(async () => { await Promise.resolve(); await Promise.resolve() }) + } + + it('redeems a connect ticket itself after an inline login', async () => { + completeConnectHandoff.mockResolvedValue(undefined) + testState.authenticatedApi = apiWithHandoff() + await render() + + await postTicket() + + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith('c'.repeat(64)) + }) + + it('leaves redemption to the app shell when rendered inside it', async () => { + testState.authenticatedApi = apiWithHandoff() + await render( + {}}> + + , + ) + + await postTicket() + + // The shell's own ConnectHandoffListener (not mounted here) is the one that would redeem it. + expect(completeConnectHandoff).not.toHaveBeenCalled() + }) +}) diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.tsx index dd967ad2ff..5a25a0f55f 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.tsx @@ -8,6 +8,7 @@ import { Button, Dialog, DropdownMenu, Select, Tooltip, useKumoToastManager } fr import { ArrowsOutSimple, ArrowLeft, ArrowSquareOut, DotsThree, DownloadSimple, Lightning, Plus, Robot, Sparkle, Star, Trash, X } from '@phosphor-icons/react' import { useAuth } from './useAuth' +import { useOptionalAuthenticatedApi } from './AuthContext' import LoginPage from './LoginPage' import { normalizeResourceUrl } from './resourceMatching' import { @@ -22,6 +23,7 @@ import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER } from './components/menuStyl import { useDocumentTitle } from './useDocumentTitle' import { AccountsSubscriberAdapter } from './accountsSubscriber' import { useDialogSelectPortalContainer } from './useDialogSelectPortalContainer' +import { openConnectWindow, useConnectHandoffListener } from './connectHandoff' interface Props { rpcStub: RpcStub @@ -39,6 +41,16 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { const { isAuthenticated, authenticatedApi, isLoading: authLoading, login } = useAuth(rpcStub) const toasts = useKumoToastManager() + // A signed-out visitor who logs in here does so through this page's own useAuth(); the root stays + // in its standalone branch with no AuthProvider, so the app shell's ConnectHandoffListener is not + // mounted and the connect popups below would never complete. Listen here in that case only: when + // the shell is authenticated its listener is already live, and a ticket can be redeemed once. + const shellAuth = useOptionalAuthenticatedApi() + const onHandoffError = useCallback((message: string) => { + toasts.add({ title: 'Could not complete the connection', description: message, variant: 'error' }) + }, [toasts]) + useConnectHandoffListener(shellAuth ? null : authenticatedApi, onHandoffError) + const [blueprint, setBlueprint] = useState(null) useDocumentTitle(blueprint?.metadata.title) const [loading, setLoading] = useState(true) @@ -194,8 +206,8 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { setConnectingVendor(vendorId) try { const result = await authenticatedApi.connectAccount(vendorId) - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Complete the account connection in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Complete the account connection in the pop-up window.', variant: 'success' }) } catch (err) { console.error('Failed to initiate connection:', err) toasts.add({ title: 'Failed to start connection flow', variant: 'error' }) @@ -209,8 +221,8 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { setReconnectingAccountId(accountId) try { const result = await authenticatedApi.reconnectAccount(accountId) - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Complete the account reconnect in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Complete the account reconnect in the pop-up window.', variant: 'success' }) } catch (err) { console.error('Failed to initiate reconnect:', err) toasts.add({ title: 'Failed to start reconnect flow', variant: 'error' }) diff --git a/packages/workshop-frontend/src/ConnectAccountModal.tsx b/packages/workshop-frontend/src/ConnectAccountModal.tsx deleted file mode 100644 index e2c289e0aa..0000000000 --- a/packages/workshop-frontend/src/ConnectAccountModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useEffect } from 'react' -import { Dialog, Text, Loader, useKumoToastManager } from '@cloudflare/kumo' -import { RpcStub } from 'capnweb' -import { AuthenticatedApi, GatekeeperVendorFilter } from '@gadgets/workshop-shared/api' -import { VendorDescription } from '@gadgets/workshop-shared/gatekeeper' -import VendorCard from './VendorCard' - -interface ConnectAccountModalProps { - visible: boolean - onCancel: () => void - onInitiated: () => void - authenticatedApi: RpcStub - /** Optional filter to only show vendors supporting certain features */ - filter?: GatekeeperVendorFilter -} - -interface VendorOption { - id: string - description: VendorDescription -} - -export default function ConnectAccountModal({ - visible, - onCancel, - onInitiated, - authenticatedApi, - filter, -}: ConnectAccountModalProps) { - const toasts = useKumoToastManager() - const [connecting, setConnecting] = useState(null) - const [vendors, setVendors] = useState([]) - const [vendorsLoading, setVendorsLoading] = useState(true) - - // Fetch vendors when modal opens - useEffect(() => { - if (!visible) { - setConnecting(null) - return - } - - const fetchVendors = async () => { - setVendorsLoading(true) - try { - const vendorList = await authenticatedApi.listGatekeeperVendors(filter) - const unavailable = vendorList.filter(v => v.unavailable) - if (unavailable.length > 0) { - toasts.add({ - title: `Some services are temporarily unavailable: ${unavailable.map(v => v.id).join(', ')}`, - variant: 'warning', - }) - } - setVendors(vendorList.filter(v => !v.unavailable).map(v => ({ id: v.id, description: v.description }))) - } catch (error) { - console.error('Failed to fetch vendors:', error) - toasts.add({ title: 'Failed to load available services', variant: 'error' }) - } finally { - setVendorsLoading(false) - } - } - - fetchVendors() - }, [visible, authenticatedApi, filter]) - - const handleConnect = async (vendorId: string) => { - setConnecting(vendorId) - try { - const result = await authenticatedApi.connectAccount(vendorId) - window.open(result.url, '_blank', 'noopener,noreferrer') - onInitiated() - } catch (error) { - console.error('Failed to initiate connection:', error) - toasts.add({ title: 'Failed to start connection flow', variant: 'error' }) - setConnecting(null) - } - } - - return ( - { if (!open) onCancel() }}> - - Connect Account - {vendorsLoading ? ( -
- -
- ) : vendors.length === 0 ? ( -
- No services available to connect. -
- ) : ( -
- {vendors.map(vendor => ( - handleConnect(vendor.id)} - loading={connecting === vendor.id} - disabled={connecting !== null && connecting !== vendor.id} - /> - ))} -
- )} -
-
- ) -} diff --git a/packages/workshop-frontend/src/ConnectHandoffListener.tsx b/packages/workshop-frontend/src/ConnectHandoffListener.tsx new file mode 100644 index 0000000000..9f84023f3f --- /dev/null +++ b/packages/workshop-frontend/src/ConnectHandoffListener.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react' +import { useKumoToastManager } from '@cloudflare/kumo' +import { useAuthenticatedApi } from './AuthContext' +import { useConnectHandoffListener } from './connectHandoff' + +/** + * Mounted once inside the authenticated shell (below the toast provider): completes gatekeeper + * connect flows whose popup reports back to this window, surfacing a rejected ticket as a toast. + */ +export function ConnectHandoffListener(): null { + const { authenticatedApi } = useAuthenticatedApi() + const toasts = useKumoToastManager() + const onError = useCallback((message: string) => { + toasts.add({ title: 'Could not complete the connection', description: message, variant: 'error' }) + }, [toasts]) + useConnectHandoffListener(authenticatedApi, onError) + return null +} diff --git a/packages/workshop-frontend/src/GatekeeperModal.tsx b/packages/workshop-frontend/src/GatekeeperModal.tsx index e591c4c6c7..7e53793141 100644 --- a/packages/workshop-frontend/src/GatekeeperModal.tsx +++ b/packages/workshop-frontend/src/GatekeeperModal.tsx @@ -36,6 +36,7 @@ import { reportIssue } from './errorReporting' import { useSiteName } from './ServerConfigContext' import { AccountsSubscriberAdapter } from './accountsSubscriber' import { useDialogSelectPortalContainer } from './useDialogSelectPortalContainer' +import { openConnectWindow } from './connectHandoff' export interface GatekeeperModalProps { open: boolean @@ -587,8 +588,8 @@ export default function GatekeeperModal({ setConnectingVendor(vendorId) try { const result = await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns) - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Complete the account connection in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Complete the account connection in the pop-up window.', variant: 'success' }) } catch (error) { console.error('Failed to initiate connection:', error) reportIssue('gatekeeper.connect-start', error, { gatekeeperVendorId: vendorId }) @@ -609,8 +610,8 @@ export default function GatekeeperModal({ try { const result = await authenticatedApi.ensureAccountResources(accountId, missing) if (result.url) { - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Grant the additional access in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Grant the additional access in the pop-up window.', variant: 'success' }) } // The new grant arrives via subscribeConnectedAccounts(); the account's flag then clears and // the configurator loads automatically. @@ -629,8 +630,8 @@ export default function GatekeeperModal({ setReconnectingAccountId(accountId) try { const result = await authenticatedApi.reconnectAccount(accountId) - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Complete the account reconnect in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Complete the account reconnect in the pop-up window.', variant: 'success' }) } catch (error) { console.error('Failed to initiate reconnect:', error) reportIssue('gatekeeper.reconnect-start', error, { diff --git a/packages/workshop-frontend/src/ObserverConfigModal.test.tsx b/packages/workshop-frontend/src/ObserverConfigModal.test.tsx index 83226dd67e..1cbad2bfae 100644 --- a/packages/workshop-frontend/src/ObserverConfigModal.test.tsx +++ b/packages/workshop-frontend/src/ObserverConfigModal.test.tsx @@ -119,6 +119,13 @@ function fakeApi( } as unknown as RpcStub } +// The popup openConnectWindow gets back: opened blank, then navigated to the connect URL. +function mockConnectPopup() { + const popup = { close() {}, opener: window as Window | null, location: { replace: vi.fn<(url: string) => void>() } } + vi.spyOn(window, 'open').mockImplementation(() => popup as unknown as Window) + return popup +} + describe('ObserverConfigModal account selection', () => { let root: Root | undefined let container: HTMLDivElement | undefined @@ -191,7 +198,7 @@ describe('ObserverConfigModal account selection', () => { const connectAccount = vi.fn< (vendorId: string, resourceUrlPatterns?: string[]) => Promise<{ url: string }> >().mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) - vi.spyOn(window, 'open').mockImplementation(() => null) + const popup = mockConnectPopup() const rendered = await render([], { api: fakeApi([], { connectAccount }), }) @@ -202,9 +209,8 @@ describe('ObserverConfigModal account selection', () => { await act(async () => connect!.click()) expect(connectAccount).toHaveBeenCalledWith('google', [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith( - 'https://accounts.google.test/oauth', '_blank', 'noopener,noreferrer', - ) + expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') }) it('expands an existing account grant before allowing verification', async () => { @@ -212,7 +218,7 @@ describe('ObserverConfigModal account selection', () => { (accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }> >() .mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) - vi.spyOn(window, 'open').mockImplementation(() => null) + const popup = mockConnectPopup() const underScoped = account(1, 'dan@cloudflare.com', [GMAIL_RESOURCE_PATTERN]) const rendered = await render([underScoped], { api: fakeApi([underScoped], { ensureAccountResources }), @@ -229,9 +235,8 @@ describe('ObserverConfigModal account selection', () => { await act(async () => grant!.click()) expect(ensureAccountResources).toHaveBeenCalledWith(1, [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith( - 'https://accounts.google.test/oauth', '_blank', 'noopener,noreferrer', - ) + expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') expect(rendered.textContent).not.toContain('Ready') expect(verify?.disabled).toBe(true) }) @@ -240,7 +245,7 @@ describe('ObserverConfigModal account selection', () => { const ensureAccountResources = vi.fn< (accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }> >().mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) - vi.spyOn(window, 'open').mockImplementation(() => null) + const popup = mockConnectPopup() const legacy = account(1, 'dan@cloudflare.com') const rendered = await render([legacy], { api: fakeApi([legacy], { ensureAccountResources }), @@ -256,9 +261,8 @@ describe('ObserverConfigModal account selection', () => { await act(async () => grant!.click()) expect(ensureAccountResources).toHaveBeenCalledWith(1, [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith( - 'https://accounts.google.test/oauth', '_blank', 'noopener,noreferrer', - ) + expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') }) it('allows verification when the gatekeeper confirms an unknown grant needs no OAuth', async () => { diff --git a/packages/workshop-frontend/src/ObserverConfigModal.tsx b/packages/workshop-frontend/src/ObserverConfigModal.tsx index 4e11f5b5bb..0e73422272 100644 --- a/packages/workshop-frontend/src/ObserverConfigModal.tsx +++ b/packages/workshop-frontend/src/ObserverConfigModal.tsx @@ -17,6 +17,7 @@ import { import { WorkshopButton } from './components/WorkshopControls' import Avatar from './components/Avatar' import { AccountsSubscriberAdapter } from './accountsSubscriber' +import { openConnectWindow } from './connectHandoff' // Shown when a non-owner opens a shared Gadget that reads data through one or more gatekeeper // bindings, and they haven't yet chosen which of their own connected accounts to use for each one. @@ -215,7 +216,7 @@ export default function ObserverConfigModal({ vendorId, required.length > 0 ? required : undefined, ) - window.open(url, '_blank', 'noopener,noreferrer') + openConnectWindow(url) } } catch (err) { console.error('Failed to initiate connection:', err) @@ -229,7 +230,7 @@ export default function ObserverConfigModal({ setReconnecting(accountId) try { const { url } = await authenticatedApi.reconnectAccount(accountId) - window.open(url, '_blank', 'noopener,noreferrer') + openConnectWindow(url) // Subscription fires add() with credentialsValid:true on completion, clearing `reconnecting`. } catch (err) { console.error('Failed to initiate reconnection:', err) @@ -249,7 +250,7 @@ export default function ObserverConfigModal({ setGranting(account.id) try { const { url } = await authenticatedApi.ensureAccountResources(account.id, missing) - if (url) window.open(url, '_blank', 'noopener,noreferrer') + if (url) openConnectWindow(url) else { // The gatekeeper confirmed this account already has access. Update the modal so the user can // continue without an OAuth flow. diff --git a/packages/workshop-frontend/src/OnboardingWizard.tsx b/packages/workshop-frontend/src/OnboardingWizard.tsx index 6c43198bc6..90885796ba 100644 --- a/packages/workshop-frontend/src/OnboardingWizard.tsx +++ b/packages/workshop-frontend/src/OnboardingWizard.tsx @@ -32,6 +32,7 @@ import { useSiteName } from './ServerConfigContext' import SiteLogo from './components/SiteLogo' import { useDocumentTitle } from './useDocumentTitle' import { AccountsSubscriberAdapter } from './accountsSubscriber' +import { openConnectWindow } from './connectHandoff' // ─── constants ────────────────────────────────────────────────────────────────── @@ -252,7 +253,7 @@ export default function OnboardingWizard({ setConnectingVendorId(vendorId) try { const { url } = await authenticatedApi.connectAccount(vendorId) - window.open(url, '_blank', 'noopener,noreferrer') + openConnectWindow(url) } catch (err) { console.error('Failed to start connection:', err) toasts.add({ title: 'Failed to start connection', variant: 'error' }) diff --git a/packages/workshop-frontend/src/ResourcePicker.tsx b/packages/workshop-frontend/src/ResourcePicker.tsx index e9b565e0cb..79169e9ddd 100644 --- a/packages/workshop-frontend/src/ResourcePicker.tsx +++ b/packages/workshop-frontend/src/ResourcePicker.tsx @@ -11,6 +11,7 @@ import { PICKER_CAPTION, PICKER_EMPTY, PICKER_ROW, PICKER_ROW_ACTIVE, TabHint, } from './components/pickerRows' import { AccountsSubscriberAdapter } from './accountsSubscriber' +import { openConnectWindow } from './connectHandoff' export interface VendorOption { id: string @@ -399,7 +400,7 @@ export default function ResourcePicker({ setConnectingVendor(vendorId) try { const result = await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns) - window.open(result.url, '_blank', 'noopener,noreferrer') + openConnectWindow(result.url) } catch (error) { console.error('Failed to initiate connection:', error) toasts.add({ title: 'Failed to start connection flow', variant: 'error' }) @@ -416,8 +417,8 @@ export default function ResourcePicker({ try { const result = await authenticatedApi.ensureAccountResources(accountId, resourceUrlPatterns) if (result.url) { - window.open(result.url, '_blank', 'noopener,noreferrer') - toasts.add({ title: 'Grant the additional access in the new tab.', variant: 'success' }) + openConnectWindow(result.url) + toasts.add({ title: 'Grant the additional access in the pop-up window.', variant: 'success' }) } } catch (error) { console.error('Failed to request additional access:', error) @@ -433,7 +434,7 @@ export default function ResourcePicker({ setReconnectingAccount(accountId) try { const result = await authenticatedApi.reconnectAccount(accountId) - window.open(result.url, '_blank', 'noopener,noreferrer') + openConnectWindow(result.url) // The subscription will fire add() with credentialsValid: true when reconnect completes. // The reconnectingAccount state is cleared at that point. } catch (error) { diff --git a/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx b/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx new file mode 100644 index 0000000000..06bb0516cc --- /dev/null +++ b/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { AuthVendorInfo, LoginAttempt, PublicApi } from '@gadgets/workshop-shared/api' +import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, +} from '@gadgets/workshop-shared/gatekeeper' +import { gatekeeperOrigin } from '../../connectHandoff' +import OAuthButtons from './OAuthButtons' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const VENDORS: AuthVendorInfo[] = [{ vendorId: 'github', displayName: 'GitHub' }] +const TICKET = 'b'.repeat(64) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { resolve = next }) + return { promise, resolve } +} + +// Lets pending promises and React flush. +const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve() }) + +describe('OAuthButtons', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + const claim = vi.fn<(ticket: string) => Promise>() + const attempt = { claim, [Symbol.dispose]() {} } as unknown as RpcStub + const popup = { closed: false, close: vi.fn<() => void>() } as unknown as Window + + function mount(rpcStub: RpcStub, onSuccess = vi.fn<() => void>()) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root!.render()) + return onSuccess + } + + const clickSignIn = () => act(async () => { container!.querySelector('button')!.click() }) + + const deliver = (source: Window | null, ticket = TICKET) => window.dispatchEvent( + new MessageEvent('message', { + data: { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket }, origin: gatekeeperOrigin(), source, + })) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + vi.restoreAllMocks() + vi.useRealTimers() + claim.mockReset() + localStorage.clear() + }) + + it('claims only the ticket its own popup posts', async () => { + vi.spyOn(window, 'open').mockReturnValue(popup) + claim.mockResolvedValue('alice@example.com:secret') + const rpcStub = { + startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), + } as unknown as RpcStub + const onSuccess = mount(rpcStub) + + await clickSignIn() + await settle() + expect(window.open).toHaveBeenCalledWith( + 'https://gk.example/login', 'gatekeeper-login', 'popup,width=520,height=680') + + // A ticket from some other window (an account-connect popup, say) is not this attempt's. + deliver({ close() {} } as unknown as Window) + await settle() + expect(claim).not.toHaveBeenCalled() + + deliver(popup) + await settle() + expect(claim).toHaveBeenCalledExactlyOnceWith(TICKET) + expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + expect(onSuccess).toHaveBeenCalledOnce() + expect(popup.close).toHaveBeenCalled() + }) + + it('keeps listening after the popup handle dies, and claims a broadcast ticket', async () => { + // A provider that isolates its pages with COOP severs the opener mid-flow: the handle reports + // closed while the flow is still running, and the handoff page reaches us over the channel. + const severed = { closed: false, close: vi.fn<() => void>() } as unknown as Window + vi.spyOn(window, 'open').mockReturnValue(severed) + claim.mockResolvedValue('alice@example.com:secret') + const rpcStub = { + startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), + } as unknown as RpcStub + const onSuccess = mount(rpcStub) + const button = () => container!.querySelector('button')! + + await clickSignIn() + await settle() + expect(button().disabled).toBe(true) + + ;(severed as { closed: boolean }).closed = true + await act(() => new Promise(resolve => setTimeout(resolve, 600))) + // Not treated as a cancellation: the buttons come back, the attempt stays live. + expect(button().disabled).toBe(false) + expect(container!.textContent).not.toContain('cancelled') + expect(claim).not.toHaveBeenCalled() + + const sender = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + // The page repeats its broadcast until a Workshop window acknowledges the ticket. + const acked = new Promise(resolve => { + sender.addEventListener('message', (event: MessageEvent) => resolve(event.data), { once: true }) + }) + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await vi.waitFor(() => expect(claim).toHaveBeenCalledExactlyOnceWith(TICKET)) + await settle() + expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + expect(onSuccess).toHaveBeenCalledOnce() + expect(await acked).toEqual({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket: TICKET }) + sender.close() + }) + + it('keeps waiting when a broadcast ticket belongs to another attempt', async () => { + // A broadcast has no source to filter on, so the channel may carry another tab's sign-in ticket + // or an account-connect ticket first. The server answers null for those; ours still lands. + const own = { closed: false, close: vi.fn<() => void>() } as unknown as Window + vi.spyOn(window, 'open').mockReturnValue(own) + const FOREIGN = 'f'.repeat(64) + claim.mockImplementation(async ticket => ticket === TICKET ? 'alice@example.com:secret' : null) + const rpcStub = { + startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), + } as unknown as RpcStub + const onSuccess = mount(rpcStub) + const button = () => container!.querySelector('button')! + + await clickSignIn() + await settle() + const sender = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: FOREIGN }) + await vi.waitFor(() => expect(claim).toHaveBeenCalledExactlyOnceWith(FOREIGN)) + await settle() + expect(localStorage.getItem('authToken')).toBeNull() + expect(onSuccess).not.toHaveBeenCalled() + expect(container!.textContent).not.toMatch(/expired|verified|Could not/) + expect(button().disabled).toBe(true) + + // The foreign claim paused the popup-closed poll; closing the popup now must still hand the + // buttons back rather than leave them stuck until the right ticket arrives. + ;(own as { closed: boolean }).closed = true + await act(() => new Promise(resolve => setTimeout(resolve, 600))) + expect(button().disabled).toBe(false) + expect(container!.textContent).not.toContain('cancelled') + + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + sender.close() + await vi.waitFor(() => expect(claim).toHaveBeenCalledWith(TICKET)) + await settle() + expect(claim).toHaveBeenCalledTimes(2) + expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + expect(onSuccess).toHaveBeenCalledOnce() + }) + + it('opens nothing if it was unmounted while the sign-in was starting', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(popup) + const start = deferred<{ url: string; attempt: RpcStub }>() + const dispose = vi.fn<() => void>() + const rpcStub = { + startGatekeeperLogin: () => start.promise, + } as unknown as RpcStub + mount(rpcStub) + + await clickSignIn() + act(() => root?.unmount()) + root = undefined + start.resolve({ + url: 'https://gk.example/login', + attempt: { claim, [Symbol.dispose]: dispose } as unknown as RpcStub, + }) + await settle() + + expect(open).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx b/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx index 0c7c6fdbf6..e15df97a8d 100644 --- a/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx +++ b/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx @@ -1,7 +1,11 @@ import { useEffect, useRef, useState } from 'react' import { RpcStub } from 'capnweb' import { PublicApi, AuthVendorInfo } from '@gadgets/workshop-shared/api' +import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, +} from '@gadgets/workshop-shared/gatekeeper' import { Button, Banner } from '@cloudflare/kumo' +import { connectHandoffTicket, parseHandoffEnvelope } from '../../connectHandoff' interface OAuthButtonsProps { rpcStub: RpcStub @@ -9,21 +13,33 @@ interface OAuthButtonsProps { onSuccess?: () => void } +// What an attempt's promise rejects with when it is torn down from outside (unmount, or a newer +// attempt) rather than failing: the caller then has no state to update. +const CANCELLED = Symbol('sign-in cancelled') + /** * Renders a sign-in button per auth-capable gatekeeper vendor. Clicking opens the gatekeeper's - * OAuth popup (which self-closes) and waits for the result over RPC; on success the session token is - * stored and the app re-authenticates. + * OAuth popup with this window as its opener; when the flow finishes, the popup delivers a handoff + * ticket back here, which is redeemed over RPC for the session token. The ticket is what ties the + * session to this browser: the sign-in URL alone can be finished by anyone (see connectHandoff.ts). + * On success the token is stored and the app re-authenticates. + * + * The ticket arrives over one of two transports. Normally the popup posts it to its opener. A + * provider that isolates its pages with COOP severs that opener mid-flow, though (Google stages + * this), and the handoff page then falls back to a same-origin BroadcastChannel — which reaches us + * because in production the login page shares an origin with the handoff page. A broadcast has no + * source to filter on, so a ticket heard there may be another tab's sign-in or an account-connect + * ticket; the server answers such a claim with null, and we keep listening for ours. */ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButtonsProps) { const [error, setError] = useState(null) const [pending, setPending] = useState(null) - // Track the pop-up-poll interval, the in-flight login RPC, and mounted state so we can stop a - // sign-in attempt that's still running if the component unmounts (e.g. the user navigates away - // mid-login): clear the poller, dispose the RPC (Cap'n Web treats this as a best-effort cancel and - // frees the client-side pending call), and avoid updating state on an unmounted component. - const pollRef = useRef(null) - const loginRpcRef = useRef(null) + // The attempt in flight, if any, as the function that tears it down: stops the popup poll, drops + // both ticket listeners and disposes the login RPC (Cap'n Web treats this as a best-effort cancel + // and frees the client-side pending call). Run when the component unmounts mid-login (e.g. the + // user navigates away) and when a new attempt starts, so at most one attempt is ever listening. + const attemptRef = useRef<(() => void) | null>(null) const mountedRef = useRef(true) useEffect(() => { // Re-assert on (re)mount: under StrictMode the effect runs mount→cleanup→mount, and the cleanup @@ -33,61 +49,121 @@ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButto mountedRef.current = true return () => { mountedRef.current = false - if (pollRef.current !== null) { - clearInterval(pollRef.current) - pollRef.current = null - } - if (loginRpcRef.current) { - try { loginRpcRef.current[Symbol.dispose]() } catch { /* already settled/disposed */ } - loginRpcRef.current = null - } + attemptRef.current?.() + attemptRef.current = null } }, []) if (vendors.length === 0) return null const start = async (vendorId: string) => { + attemptRef.current?.() + attemptRef.current = null setError(null) setPending(vendorId) try { const { url, attempt } = await rpcStub.startGatekeeperLogin(vendorId) - // `attempt` is the capability to receive the session token; track it so we can dispose it - // (cancelling the wait server-side) if the component unmounts mid-login. - loginRpcRef.current = attempt as unknown as Disposable - // NB: don't pass "noopener" — window.open() returns null with it, so we couldn't tell a real - // pop-up block from a successful open (nor watch for the user closing it). + // `attempt` is the capability to redeem the session token. + const dispose = () => { + try { (attempt as unknown as Disposable)[Symbol.dispose]() } catch { /* already disposed */ } + } + if (!mountedRef.current) { + // Unmounted while the RPC was in flight: the cleanup above has already run, so nothing may + // be opened or registered now. + dispose() + return + } + // Unlike account-connect popups (see openConnectWindow), a login popup deliberately keeps this + // window as its opener: sign-in providers are admin-allowlisted, and the opener is how the + // ticket normally comes back (postMessage). Don't pass "noopener" — window.open() returns null + // with it, indistinguishable from a pop-up block. const popup = window.open(url, 'gatekeeper-login', 'popup,width=520,height=680') if (!popup) { - try { (attempt as unknown as Disposable)[Symbol.dispose]() } catch { /* already disposed */ } - loginRpcRef.current = null + dispose() throw new Error('Pop-up blocked. Please allow pop-ups and try again.') } - // Resolve when the gatekeeper finishes, or reject if the user closes the pop-up first. + // Resolve once a ticket arrives and the claim succeeds; reject if the claim fails or the + // attempt is torn down. const token = await new Promise((resolve, reject) => { let settled = false - const finish = (fn: () => void) => { + let poll: number | null = null + const channel = 'BroadcastChannel' in globalThis + ? new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + : null + + function stopPolling() { + if (poll !== null) { clearInterval(poll); poll = null } + } + // An arrow, not a declaration: only a closure created after the null check above sees + // `popup` narrowed. + const startPolling = () => { + if (poll !== null) return + poll = window.setInterval(() => { + if (!popup.closed) return + // Not necessarily a cancellation: a provider that swaps browsing context groups (COOP) + // reports the popup closed while the flow is still running, and its ticket will arrive + // over the channel. So just hand the buttons back and keep listening; if the user really + // closed it, nothing arrives and the attempt ends with the next one or on unmount. + stopPolling() + if (mountedRef.current) setPending(null) + }, 500) + } + function finish(fn: () => void) { if (settled) return settled = true - if (pollRef.current !== null) { clearInterval(pollRef.current); pollRef.current = null } - // Dispose the attempt stub: cancels the in-flight wait() (e.g. pop-up closed), no-op if it - // already settled. - try { (attempt as unknown as Disposable)[Symbol.dispose]() } catch { /* already settled */ } - loginRpcRef.current = null + attemptRef.current = null + stopPolling() + window.removeEventListener('message', onMessage) + channel?.close() + dispose() fn() } - pollRef.current = window.setInterval(() => { - if (popup.closed) finish(() => reject(new Error('Sign-in was cancelled.'))) - }, 500) - attempt.wait() - .then(t => finish(() => resolve(t))) - .catch(e => finish(() => reject(e instanceof Error ? e : new Error('Could not sign in')))) + // Claims may overlap: a foreign ticket answered with null must not hold up the real one + // behind it, and `finish` settles only once. Polling pauses during a claim so a popup that + // closes itself on completion is not read as a cancellation, and resumes after a foreign + // ticket, or closing the popup afterwards would leave the buttons stuck. + function claimTicket(ticket: string) { + if (settled) return + stopPolling() + attempt.claim(ticket) + .then(t => { + if (settled) return + if (t === null) { + startPolling() + return + } + // A popup whose opener COOP severed broadcasts, and repeats until acknowledged. + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + channel?.postMessage({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket }) + finish(() => resolve(t)) + }) + .catch(e => finish(() => reject(e instanceof Error ? e : new Error('Could not sign in')))) + } + function onMessage(event: MessageEvent) { + // Unlike the connect listener, this page holds the popup handle, so a ticket from any + // other window (say, an account-connect popup that outlived a logout) is not ours: claiming + // it would only burn this attempt. + if (event.source !== popup) return + const ticket = connectHandoffTicket(event) + if (ticket !== null) claimTicket(ticket) + } + + window.addEventListener('message', onMessage) + channel?.addEventListener('message', (event: MessageEvent) => { + const ticket = parseHandoffEnvelope(event.data) + if (ticket !== null) claimTicket(ticket) + }) + startPolling() + attemptRef.current = () => finish(() => reject(CANCELLED)) }) + // Best-effort: after a COOP swap the handle is dead, and the page closes itself anyway. + try { popup.close() } catch { /* severed */ } if (!mountedRef.current) return // user navigated away mid-flow; drop the result localStorage.setItem('authToken', token) if (onSuccess) onSuccess() else window.location.reload() } catch (err) { - if (!mountedRef.current) return + if (err === CANCELLED || !mountedRef.current) return setError(err instanceof Error ? err.message : 'Could not sign in') setPending(null) } diff --git a/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx b/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx index 8a30e620c4..f5f8cf7393 100644 --- a/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx +++ b/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx @@ -5,6 +5,7 @@ import { CloudWarning, Lightning } from '@phosphor-icons/react' import { useOptionalAuthenticatedApi } from '../../AuthContext' import { buildAddCreditsUrl } from './creditsUrl' import ResetCountdown from './ResetCountdown' +import { openConnectWindow } from '../../connectHandoff' interface OutOfCreditsModalProps { open: boolean @@ -61,9 +62,13 @@ export default function OutOfCreditsModal({ open, onClose }: OutOfCreditsModalPr setConnecting(true) try { const { url } = await auth.authenticatedApi.connectAccount('cloudflare', []) - window.open(url, '_blank', 'noopener,noreferrer') - } catch { - // ignore + openConnectWindow(url) + } catch (err) { + toasts.add({ + title: 'Failed to start Cloudflare connection', + description: err instanceof Error ? err.message : undefined, + variant: 'error', + }) } finally { setConnecting(false) } diff --git a/packages/workshop-frontend/src/components/billing/UsageSettings.tsx b/packages/workshop-frontend/src/components/billing/UsageSettings.tsx index 53c47f508e..b8525fc4b3 100644 --- a/packages/workshop-frontend/src/components/billing/UsageSettings.tsx +++ b/packages/workshop-frontend/src/components/billing/UsageSettings.tsx @@ -7,6 +7,7 @@ import { useAuthenticatedApi } from '../../AuthContext' import { useCloudflareLimitsEnabled } from '../../ServerConfigContext' import { buildAddCreditsUrl } from './creditsUrl' import ResetCountdown from './ResetCountdown' +import { openConnectWindow } from '../../connectHandoff' /** * Shows the user's free-tier usage and Cloudflare connection / credit status on the profile page. @@ -61,7 +62,7 @@ export default function UsageSettings() { // Connecting (or signing in with) Cloudflare is handled by the Cloudflare gatekeeper. Open its // OAuth popup; the connected-accounts subscription + focus refresh pick up the result. const { url } = await authenticatedApi.connectAccount('cloudflare', []) - window.open(url, '_blank', 'noopener,noreferrer') + openConnectWindow(url) } catch { toasts.add({ title: 'Failed to start Cloudflare connection', variant: 'error' }) } finally { diff --git a/packages/workshop-frontend/src/connectHandoff.test.tsx b/packages/workshop-frontend/src/connectHandoff.test.tsx new file mode 100644 index 0000000000..40937e301e --- /dev/null +++ b/packages/workshop-frontend/src/connectHandoff.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { AuthenticatedApi } from '@gadgets/workshop-shared/api' +import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, +} from '@gadgets/workshop-shared/gatekeeper' +import { gatekeeperOrigin, openConnectWindow, useConnectHandoffListener } from './connectHandoff' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const TICKET = 'a'.repeat(64) + +function Listener({ api, onError }: { api: RpcStub | null; onError: (m: string) => void }) { + useConnectHandoffListener(api, onError) + return null +} + +function deliver(data: unknown, origin = gatekeeperOrigin(), source: Window | null = null) { + window.dispatchEvent(new MessageEvent('message', { data, origin, source })) +} + +// Lets the RPC promise settle and React flush. +const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve() }) + +// What a disowned popup on our origin does: broadcast on the channel named after the message type. +// Delivery is asynchronous, so callers wait a tick before asserting. +async function broadcast(...messages: unknown[]) { + const channel = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + for (const message of messages) channel.postMessage(message) + await new Promise(resolve => setTimeout(resolve, 20)) + channel.close() +} + +// What `openConnectWindow` leaves behind in this tab: the marker that makes a broadcast ticket ours, +// stamped with when the popup was opened. +const CONNECT_PENDING_KEY = 'gadgets.connectPending' +const pending = (openedAt = Date.now()) => sessionStorage.setItem(CONNECT_PENDING_KEY, String(openedAt)) + +// The next acknowledgement posted on the channel, as the handoff page hears it (the page's own +// broadcasts pass this receiver too, so anything but an ack is skipped). +function nextAck(): Promise { + const receiver = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + return new Promise(resolve => { + receiver.addEventListener('message', (event: MessageEvent) => { + if (event.data?.type !== CONNECT_HANDOFF_ACK_MESSAGE_TYPE) return + receiver.close() + resolve(event.data) + }) + }) +} + +describe('useConnectHandoffListener', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + const completeConnectHandoff = vi.fn<(ticket: string) => Promise>() + const onError = vi.fn<(message: string) => void>() + const api = { completeConnectHandoff } as unknown as RpcStub + + function mount() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root!.render()) + } + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + vi.restoreAllMocks() + completeConnectHandoff.mockReset() + onError.mockReset() + sessionStorage.clear() + }) + + it('redeems a well-formed ticket from the gatekeeper origin and closes the popup', async () => { + completeConnectHandoff.mockResolvedValue(undefined) + const popup = { close: vi.fn<() => void>() } as unknown as Window + mount() + + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, gatekeeperOrigin(), popup) + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) + expect(popup.close).toHaveBeenCalledOnce() + expect(onError).not.toHaveBeenCalled() + }) + + it('redeems a ticket broadcast on the same-origin channel, closing nothing itself', async () => { + // A disowned popup on our own origin has no opener to post to; it broadcasts and closes itself. + completeConnectHandoff.mockResolvedValue(undefined) + pending() + mount() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) + expect(onError).not.toHaveBeenCalled() + }) + + it('ignores a broadcast ticket when this tab opened no connect', async () => { + // Every Workshop tab on the origin hears the channel; only the one that opened the popup redeems, + // so the others neither race it nor toast that the attempt expired. + mount() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + + it('spends the marker on a successful broadcast redemption and acknowledges it', async () => { + completeConnectHandoff.mockResolvedValue(undefined) + const popup = { opener: null, location: { replace: vi.fn<(url: string) => void>() } } + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + const before = Date.now() + mount() + openConnectWindow('https://gk.example/connect') + expect(Number(sessionStorage.getItem(CONNECT_PENDING_KEY))).toBeGreaterThanOrEqual(before) + + const heard = nextAck() + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) + // Spent: a later broadcast is not this tab's. The ack is what stops the page repeating. + expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).toBeNull() + expect(await heard).toEqual({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket: TICKET }) + expect(onError).not.toHaveBeenCalled() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'b'.repeat(64) }) + await settle() + expect(completeConnectHandoff).toHaveBeenCalledOnce() + }) + + it('keeps the marker when a broadcast redemption fails, and tries each ticket once', async () => { + // A sibling tab's or a sign-in ticket heard first is rejected by the server; that must not + // cost this tab its own ticket, which is still on its way. The page repeats its broadcast until + // acked, so a ticket already tried is ignored rather than toasted again. + completeConnectHandoff + .mockRejectedValueOnce(new Error('This connection attempt has expired.')) + .mockResolvedValueOnce(undefined) + pending() + mount() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) + expect(onError).toHaveBeenCalledExactlyOnceWith('This connection attempt has expired.') + expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).not.toBeNull() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + expect(completeConnectHandoff).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledOnce() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'b'.repeat(64) }) + await settle() + expect(completeConnectHandoff).toHaveBeenCalledTimes(2) + expect(completeConnectHandoff).toHaveBeenLastCalledWith('b'.repeat(64)) + expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).toBeNull() + }) + + it('ignores a marker older than the connect lifetime', async () => { + // An abandoned popup's flow can no longer complete once its connect and OAuth nonces have both + // expired, so its marker must stop this tab racing its siblings for their tickets. + pending(Date.now() - 31 * 60 * 1000) + mount() + + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + + it('ignores a malformed broadcast', async () => { + pending() + mount() + + await broadcast( + { type: 'gadgets.connect-handoff.v0', ticket: TICKET }, + { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'not-a-ticket' }, + 'ticket', + ) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + + it('ignores messages from any other origin, type, or shape', async () => { + mount() + + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, 'https://evil.example') + deliver({ type: 'gadgets.connect-handoff.v0', ticket: TICKET }) + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'not-a-ticket' }) + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET.toUpperCase() }) + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE }) + deliver('ticket') + deliver(null) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + + it('reports a rejected ticket and leaves the popup open', async () => { + completeConnectHandoff.mockRejectedValue(new Error('This connection attempt has expired.')) + const popup = { close: vi.fn<() => void>() } as unknown as Window + mount() + + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, gatekeeperOrigin(), popup) + await settle() + + expect(onError).toHaveBeenCalledExactlyOnceWith('This connection attempt has expired.') + expect(popup.close).not.toHaveBeenCalled() + }) + + it('listens for nothing when given no session', async () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root!.render()) + + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + }) + + it('stops listening once unmounted, on both transports', async () => { + pending() + mount() + act(() => root?.unmount()) + root = undefined + + deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) + await settle() + + expect(completeConnectHandoff).not.toHaveBeenCalled() + }) +}) + +describe('openConnectWindow', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + // A popup as window.open returns it: an opener pointing back at us, and a location to navigate. + function fakePopup() { + return { + opener: window as Window | null, + location: { replace: vi.fn<(url: string) => void>() }, + } + } + + it('opens an empty popup, disowns it, then navigates it when the gatekeepers share our origin', () => { + vi.stubEnv('VITE_BACKEND_HOST', window.location.host) + const popup = fakePopup() + const open = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + + expect(openConnectWindow('https://gk.example/connect')).toBe(popup) + expect(open).toHaveBeenCalledExactlyOnceWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(open.mock.calls[0][2]).not.toContain('noopener') + // Disowned before it is navigated, so no provider page ever sees window.opener. + expect(popup.opener).toBeNull() + expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/connect') + }) + + it('keeps the opener when the gatekeepers are on another origin, as under the dev server', () => { + // A BroadcastChannel could not cross origins, so the page must be able to postMessage to us. + expect(gatekeeperOrigin()).not.toBe(window.location.origin) + const popup = fakePopup() + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + + expect(openConnectWindow('https://gk.example/connect')).toBe(popup) + expect(popup.opener).toBe(window) + expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/connect') + }) + + it('tells the user when the browser blocked the popup', () => { + vi.spyOn(window, 'open').mockReturnValue(null) + + expect(() => openConnectWindow('https://gk.example/connect')) + .toThrow('Pop-up blocked. Please allow pop-ups and try again.') + }) +}) diff --git a/packages/workshop-frontend/src/connectHandoff.ts b/packages/workshop-frontend/src/connectHandoff.ts new file mode 100644 index 0000000000..c0add140c5 --- /dev/null +++ b/packages/workshop-frontend/src/connectHandoff.ts @@ -0,0 +1,186 @@ +// The browser half of the gatekeeper connect handoff (see `GatekeeperVendor.connectAccount` in +// workshop-shared). A connect URL is a bearer capability, so the Workshop opens it as a popup; when +// the flow finishes, the gatekeeper's page delivers a single-use ticket back here — over a +// same-origin BroadcastChannel, or by postMessage to its opener where one is kept — and redeeming it +// over our authenticated session is what activates the grant. + +import { useEffect } from 'react' +import type { RpcStub } from 'capnweb' +import type { AuthenticatedApi } from '@gadgets/workshop-shared/api' +import { + CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, +} from '@gadgets/workshop-shared/gatekeeper' + +/** Host the backend (and, through the router, every gatekeeper) is served from. */ +export function getBackendHost(): string { + // Only the Vite dev server is hosted separately from the backend. Built assets are served from + // the same origin in both production and run-local mode. + if (import.meta.env.DEV) { + return import.meta.env.VITE_BACKEND_HOST?.trim() || 'localhost:8787' + } + return window.location.host +} + +/** + * Origin the handoff message arrives from: the gatekeeper connect pages are served under + * `/gatekeeper/*` on the backend host, so in production this is the Workshop's own origin. + */ +export function gatekeeperOrigin(): string { + return `${window.location.protocol}//${getBackendHost()}` +} + +const TICKET_PATTERN = /^[0-9a-f]{64}$/ + +/** + * The ticket a handoff envelope carries, or null unless `data` is a well-formed one. Origin is the + * caller's business: a `message` event's must be checked (`connectHandoffTicket`), a BroadcastChannel + * is same-origin by construction. + */ +export function parseHandoffEnvelope(data: unknown): string | null { + if (typeof data !== 'object' || data === null) return null + const { type, ticket } = data as { type?: unknown; ticket?: unknown } + if (type !== CONNECT_HANDOFF_MESSAGE_TYPE) return null + if (typeof ticket !== 'string' || !TICKET_PATTERN.test(ticket)) return null + return ticket +} + +/** + * The ticket a `message` event carries, or null unless it came from the gatekeeper origin with a + * well-formed handoff envelope. Shared by the connect listener and the sign-in buttons, so both apply + * exactly the same checks. + */ +export function connectHandoffTicket(event: MessageEvent): string | null { + if (event.origin !== gatekeeperOrigin()) return null + return parseHandoffEnvelope(event.data) +} + +/** + * Opens a connect / reconnect / ensure-resources URL as a popup. The popup is opened empty, disowned, + * and only then navigated, so the provider's pages never hold `window.opener`: a connect flow can + * land on pages the deployment does not vouch for — notably an MCP server the user pasted — and an + * opener handle would let such a page navigate this authenticated tab to a phishing page (reverse + * tabnabbing). Disowning is done by hand rather than with the `noopener` feature because that makes + * `window.open()` return null even on success, which is indistinguishable from a pop-up block. The + * completion page reaches us over a same-origin BroadcastChannel instead (`useConnectHandoffListener`). + * + * Under the Vite dev server the Workshop and the gatekeepers are on different origins, so a channel + * could not reach us; there the popup keeps its opener and the page falls back to `postMessage`. A + * provider that isolates its pages with COOP severs that opener too, and no channel crosses origins, + * so such a connect ends in dev on "couldn't reach the Workshop"; production is unaffected, the + * popup being disowned there anyway. Throws when the browser blocked the popup. + */ +export function openConnectWindow(url: string): Window { + const popup = window.open('', 'gadgets-connect', 'popup,width=520,height=680') + if (!popup) throw new Error('Pop-up blocked. Please allow pop-ups and try again.') + if (gatekeeperOrigin() === window.location.origin) popup.opener = null + markConnectPending() + popup.location.replace(url) + return popup +} + +/** + * Set in this tab's `sessionStorage` by `openConnectWindow`, so `useConnectHandoffListener` knows a + * broadcast ticket is one this tab asked for. Per-tab and reload-stable, which is exactly the scope + * wanted: the tab that opened the popup redeems, its siblings stay quiet. Holds the time it was + * set, so an abandoned popup's marker ages out instead of racing sibling tabs forever. + */ +const CONNECT_PENDING_KEY = 'gadgets.connectPending' + +/** + * How long a marker counts. A ticket can legitimately arrive up to the sum of the gatekeepers' + * initiation-nonce lifetime (10 min, e.g. spent on an endpoint form), the fresh OAuth-nonce lifetime + * (10 min, spent at the consent screen) and the Workshop's handoff lifetime (2 min) after the popup + * opened; anything later cannot be this tab's. Rounded up: the bound exists only so an abandoned + * popup's marker does not race sibling tabs forever. + */ +const CONNECT_PENDING_LIFETIME_MS = 30 * 60 * 1000 + +// Storage can be unavailable (a disabled cookie jar, a sandboxed frame); every access degrades to +// today's behaviour of redeeming whatever arrives rather than failing the connect. +function markConnectPending(): void { + try { sessionStorage.setItem(CONNECT_PENDING_KEY, String(Date.now())) } catch { /* fall back to redeeming all */ } +} + +function hasPendingConnect(): boolean { + try { + const marked = sessionStorage.getItem(CONNECT_PENDING_KEY) + return marked !== null && Date.now() - Number(marked) < CONNECT_PENDING_LIFETIME_MS + } catch { + return true + } +} + +function clearPendingConnect(): void { + try { sessionStorage.removeItem(CONNECT_PENDING_KEY) } catch { /* nothing to clear */ } +} + +/** + * Listens for the ticket a connect popup delivers and redeems it on the user's session. Two + * transports are watched: a BroadcastChannel named `CONNECT_HANDOFF_MESSAGE_TYPE` (a disowned popup + * on our own origin; the browser scopes the channel to that origin) and `message` events from the + * gatekeeper origin (a popup that kept its opener, as under the dev server). Only well-formed + * envelopes are considered; anything else is ignored silently. A popup that posted is closed once + * the Workshop has accepted the ticket. A broadcast has no source, so that page repeats its + * envelope (a tab whose session is mid-reconnect would miss a one-shot) until this tab answers with + * a `CONNECT_HANDOFF_ACK_MESSAGE_TYPE` envelope once the redemption succeeded, then closes itself. + * + * Security rests on the ticket being scoped server-side to the user who started the flow, not on + * which window sent it. The `sessionStorage` marker `openConnectWindow` sets only decides *which of + * that user's tabs* redeems a broadcast: the one that opened the popup, surviving a reload, since + * the storage is per-tab and reload-stable; its siblings stay silent instead of racing it and + * toasting "expired". The marker is spent only by a successful redemption, so a sibling's or a + * sign-in ticket heard first (which the server rejects) does not cost this tab its own, and it ages + * out after the connect-nonce lifetime so an abandoned popup's marker stops racing siblings. A + * connect whose tab was closed expires and is revoked like an abandoned one. A phished handoff page + * opened directly in the victim's own browser broadcasts to tabs none of which holds a marker, so + * nothing even reaches the server; a `message` event needs no marker, its source being the popup + * this tab itself holds. + * + * Pass `null` to listen for nothing: a ticket must be redeemed exactly once, so only one listener may + * be live per window (see `ConnectHandoffListener` and the blueprint page). + */ +export function useConnectHandoffListener( + authenticatedApi: RpcStub | null, + onError: (message: string) => void, +): void { + useEffect(() => { + if (!authenticatedApi) return + const channel = 'BroadcastChannel' in globalThis + ? new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) + : null + // `source` is the popup that posted the ticket, or null for a broadcast, whose page is told to + // close by the ack instead. + const redeem = (ticket: string, source: Window | null) => { + authenticatedApi.completeConnectHandoff(ticket).then( + () => { + if (source) { + source.close?.() + return + } + clearPendingConnect() + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. + channel?.postMessage({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket }) + }, + (err: unknown) => { onError(err instanceof Error ? err.message : String(err)) }, + ) + } + const onMessage = (event: MessageEvent) => { + const ticket = connectHandoffTicket(event) + if (ticket !== null) redeem(ticket, event.source as Window | null) + } + window.addEventListener('message', onMessage) + // Tickets already tried on this session: the page repeats its broadcast until acked, and a + // sibling tab's page may repeat too, so a ticket is redeemed (and a failure toasted) once. + const attempted = new Set() + channel?.addEventListener('message', (event: MessageEvent) => { + const ticket = parseHandoffEnvelope(event.data) + if (ticket === null || attempted.has(ticket) || !hasPendingConnect()) return + attempted.add(ticket) + redeem(ticket, null) + }) + return () => { + window.removeEventListener('message', onMessage) + channel?.close() + } + }, [authenticatedApi, onError]) +} diff --git a/packages/workshop-frontend/src/main.tsx b/packages/workshop-frontend/src/main.tsx index a71ea3a04a..9f8d841198 100644 --- a/packages/workshop-frontend/src/main.tsx +++ b/packages/workshop-frontend/src/main.tsx @@ -13,6 +13,7 @@ import './styles.css' import FrontendErrorBoundary from './FrontendErrorBoundary' import { installWorkshopErrorReporting, reportIssue } from './errorReporting' import { applySiteFavicon, cacheBustSiteLogoUrl } from './siteLogoUtils' +import { getBackendHost } from './connectHandoff'; // --------------------------------------------------------------------------- // Dev auto-login: if VITE_DEV_AUTO_LOGIN=true, automatically create/login @@ -82,15 +83,6 @@ const withTimeout = (promise: Promise, ms: number): Promise => { return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); }; -function getBackendHost(): string { - // Only the Vite dev server is hosted separately from the backend. Built assets are served from - // the same origin in both production and run-local mode. - if (import.meta.env.DEV) { - return import.meta.env.VITE_BACKEND_HOST?.trim() || 'localhost:8787'; - } - return window.location.host; -} - function startConnection(): RpcStub { lastConnectTime = Date.now(); const apiHost = getBackendHost(); diff --git a/packages/workshop-frontend/src/routes/__root.tsx b/packages/workshop-frontend/src/routes/__root.tsx index 004dd13001..5b0e477c5f 100644 --- a/packages/workshop-frontend/src/routes/__root.tsx +++ b/packages/workshop-frontend/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { AuthenticatedApi } from '@gadgets/workshop-shared/api' import { useRpcStub, useConnectionLost } from '../RpcContext' import { useAuth, CF_ACCESS_MODE } from '../useAuth' import { AuthProvider } from '../AuthContext' +import { ConnectHandoffListener } from '../ConnectHandoffListener' import { FeatureFlagsProvider } from '../FeatureFlagsContext' import Header from '../components/Header' import AppShell from '../components/AppShell/AppShell' @@ -110,6 +111,7 @@ function RootComponent() { + ; + * Redeem the handoff ticket the sign-in popup posted to this window (the `ticket` of a + * `CONNECT_HANDOFF_MESSAGE_TYPE` message, exactly as for `AuthenticatedApi.completeConnectHandoff`) + * for a session token (to store and pass to `authenticate()`, same format as `login()`). Resolves + * null when the ticket belongs to a different attempt (a broadcast can carry another window's), + * including one that arrives before this attempt has finished; in either case the attempt is + * untouched and the caller keeps listening. Rejects if the gatekeeper + * reported a failure or the attempt has expired or was already claimed. Holding this stub alone + * never yields a token: the sign-in URL is a bearer capability, and only the browser that finished + * it receives the ticket. + */ + claim(ticket: string): Promise; } /** Public API exposed to the internet. */ @@ -58,12 +64,13 @@ export interface PublicApi extends RpcTarget { /** * Begin a sign-in via an authentication gatekeeper (e.g. "google", "github", "cloudflare"). - * Returns a `url` the client opens in a new tab (the gatekeeper's OAuth popup, which self-closes) - * and an `attempt` stub whose `wait()` resolves once the popup completes. The vendor must be + * Returns a `url` the client opens as a popup with the opener retained (unlike + * `AuthenticatedApi.connectAccount`, whose popup is disowned) and an `attempt` stub whose `claim()` + * exchanges the ticket the popup posts back for the session token. The vendor must be * auth-capable and allowlisted (see ServerConfig.authVendors); throws otherwise. * - * Dispose `attempt` to abandon the sign-in (e.g. the user closed the popup); this cancels the wait - * server-side. + * Dispose `attempt` to abandon the sign-in (e.g. the user closed the popup). Nothing is cancelled + * server-side: the browser just stops listening, and an unclaimed token expires on its own. */ startGatekeeperLogin(vendorId: string): Promise<{ url: string; attempt: RpcStub }>; @@ -527,9 +534,11 @@ export interface AuthenticatedApi extends RpcTarget { /** * Connect this account to a specific account on a third-party service. Returns the URL which - * should be opened in a new tab in the user's browser to complete the authorization. When the - * authorization flow completes, the account will be added to the list, which can be observed - * through subscribeConnectedAccounts(). + * should be opened as a popup in the user's browser to complete the authorization; the Workshop + * disowns the popup before navigating it (see `openConnectWindow`), so the flow's final page + * delivers a handoff ticket over a same-origin `BroadcastChannel` (`CONNECT_HANDOFF_MESSAGE_TYPE`), + * which the client redeems with completeConnectHandoff(); only then is the account added to the + * list, which can be observed through subscribeConnectedAccounts(). * * `resourceUrlPatterns`, if given, limits the connection to the authorization needed for those * grantable resource types (those with `grantable`; see `SupportedResource`). If omitted, @@ -540,10 +549,21 @@ export interface AuthenticatedApi extends RpcTarget { */ connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise<{url: string}>; + /** + * Redeem the handoff ticket a connect popup delivered to this window (the `ticket` of a + * `CONNECT_HANDOFF_MESSAGE_TYPE` message, over the broadcast channel or, where the popup kept + * its opener, by `postMessage`). Activates the pending connect / reconnect / + * ensure-resources grant if it was started by this user, after which the account (or its + * restored credentials) appears via subscribeConnectedAccounts(). Throws if the ticket is + * unknown to this user, already redeemed, or expired. + */ + completeConnectHandoff(ticket: string): Promise; + /** * Ensure the authorization for the listed grantable resource types (by `urlPattern`) is granted - * on a connected account, expanding if needed. Returns a URL to open in a new tab to authorize - * them, or no url if nothing was needed. The updated grant is observable via + * on a connected account, expanding if needed. Returns a URL to open as a popup (disowned, as for + * connectAccount()) to authorize them, or no url if nothing was needed. Completion is + * confirmed via completeConnectHandoff(); the updated grant is then observable via * subscribeConnectedAccounts(). */ ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}>; @@ -674,8 +694,9 @@ export interface AuthenticatedApi extends RpcTarget { /** * Re-authenticate a connected account whose credentials have expired (or may be about to - * expire). Returns the URL to open in a new tab. When the OAuth flow completes, the account - * is updated and subscribers are notified with credentialsValid: true. + * expire). Returns the URL to open as a popup (disowned, as for connectAccount()). Once + * the OAuth flow completes and the client redeems the handoff via completeConnectHandoff(), the + * account is updated and subscribers are notified with credentialsValid: true. */ reconnectAccount(accountId: number): Promise<{url: string}>; diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 930589c99d..b2f1883ace 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -443,26 +443,73 @@ export type GatekeeperConnectOptions = { resourceUrlPatterns?: string[]; }; +/** + * The `type` field of the envelope `{type, ticket}` that a finished connect flow's browser tab + * delivers to the Workshop, and the name of the same-origin `BroadcastChannel` it delivers over when + * it has no opener to `postMessage` to. Versioned so the listener can ignore envelopes from an older + * or newer page. + */ +export const CONNECT_HANDOFF_MESSAGE_TYPE = "gadgets.connect-handoff.v1"; + +/** + * The `type` field of the envelope `{type, ticket}` the Workshop posts back on the same + * `BroadcastChannel` once it has redeemed a broadcast ticket, so the completion page stops repeating + * the handoff and closes. Never sent to an opener: the Workshop closes that popup itself. + */ +export const CONNECT_HANDOFF_ACK_MESSAGE_TYPE = "gadgets.connect-handoff-ack.v1"; + +/** + * What the browser tab that finished a connect flow must deliver to the Workshop, as returned by + * `GatekeeperConnectCallback.complete()` / `reconnectComplete()`. + * + * `ticket` is a single-use secret the Workshop redeems over the initiating user's authenticated RPC + * session (`AuthenticatedApi.completeConnectHandoff`); the staged grant is activated only when it + * arrives from that user. `targetOrigin` is the Workshop's origin. The completion page delivers the + * envelope one of two ways: `postMessage` to its opener with `targetOrigin` passed verbatim, so a + * browser drops the ticket if the opener is anyone else (sign-in popups keep their opener); or, for + * a connect popup the Workshop disowned before navigating it, a `BroadcastChannel` that the page + * opens only when it is itself on `targetOrigin` — the browser scopes the channel to that origin. + * Over the channel the page repeats the envelope until a Workshop tab acknowledges it + * (`CONNECT_HANDOFF_ACK_MESSAGE_TYPE`), since a tab whose session is mid-reconnect would miss a + * one-shot broadcast; the ticket is single-use server-side, so the repeats are harmless. + * Opaque to gatekeepers: they only render it into the completion page (see `connectHandoffPageHtml` + * in gatekeeper-kit). + */ +export type ConnectHandoff = { + targetOrigin: string; + ticket: string; +}; + export interface GatekeeperVendor extends WorkerEntrypoint { /** Get display info for the service, suitable for display to a user. */ describe(): Promise; /** * Start the auth flow to connect to the user's remote account. Returns the URL which the user - * should open in their browser in order to complete the flow. This URL will be opened in a new - * tab; when it completes, it should close itself using window.close(). + * should open in their browser in order to complete the flow. The Workshop opens this URL as a + * popup it has disowned, so the provider's pages hold no handle to the Workshop window. * * When the flow completes, `callback.complete()` should be called to add the connection to the - * user's list of authorizations. (`callback` can be stored.) + * user's list of authorizations. (`callback` can be stored.) It returns a `ConnectHandoff` which + * the flow's final page must deliver to the Workshop (render it with gatekeeper-kit's + * `connectHandoffPageHtml`); the connection is not active until the Workshop has redeemed it. * * A typical implementation creates a UserAccount Durable Object to manage the authorization * flow, storing the callback in its storage, then directing the user to a URL that references * the DO. Once the user completes the flow, the DO invokes the callback. The DO should set an * alarm to delete itself after some timeout if the user fails to complete the flow. * - * SECURITY: The returned URL must include a cryptographic nonce (in addition to the DO ID) to - * prevent replay attacks. The nonce should be stored in the DO and verified when the user visits - * the URL. See gatekeeper-google for a reference implementation. + * SECURITY: The returned URL is a bearer capability: anyone who opens it can finish the flow, and + * nothing about the HTTP requests ties the browser that finishes to the user who started it. So + * an attacker can start a connect and trick a victim into opening the URL, whereupon the victim's + * provider credentials would be delivered into the attacker's Workshop account. The defence is + * the handoff: the flow must end on the kit's handoff page, which delivers the ticket only to + * the Workshop's origin, and the Workshop activates the grant only when the ticket comes back + * over the initiator's own session. Until then the gatekeeper holds the + * credentials but they are reachable from no Workshop account; if the ticket is never redeemed, + * the Workshop calls `GatekeeperUser.revoke()` on the staged account. The URL must additionally + * include a cryptographic nonce (in addition to the DO ID), stored in the DO and verified when the + * user visits the URL, to prevent replay. See gatekeeper-github for a reference implementation. * * `options.scopes` selects how much access to request (default "full"): * - "full": the gatekeeper's full capability scopes (repos, docs, etc.). The resulting @@ -525,7 +572,10 @@ export interface GatekeeperVendor extends WorkerEntrypoint { export interface GatekeeperConnectCallback extends WorkerEntrypoint { /** - * Indicates the connection completed successfully. + * Indicates the connection completed successfully. The Workshop *stages* the account: it is not + * added to the user's list until the returned handoff has been redeemed from the initiating + * user's browser (see `GatekeeperVendor.connectAccount`). The caller must render the handoff into + * the page the browser lands on; if the handoff is never redeemed the Workshop revokes `user`. * * `expiresAt`, if provided, indicates when the credentials are expected to stop being * refreshable. Do not pass the expiry of a short-lived access token if the gatekeeper can @@ -534,7 +584,21 @@ export interface GatekeeperConnectCallback extends WorkerEntrypoint { * operation to fail. If not provided, the system relies on the gatekeeper calling * `credentialsExpired()` when a refresh or authorization failure is detected. */ - complete(user: Fetcher, expiresAt?: Date): Promise; + complete(user: Fetcher, expiresAt?: Date): Promise; + + /** + * Indicates a `reconnect()` / `ensureResources()` flow finished and the new credentials are + * *staged* in the gatekeeper (not yet live; see `GatekeeperUser.commitReconnect`). Returns the + * handoff the flow's final page must deliver to the Workshop. Once the Workshop has verified the + * completing browser belongs to the account's owner it calls `commitReconnect(stageId)` on the + * account, then treats the credentials as restored. + * + * `stageId` identifies the staged credentials this completion produced (gatekeeper-kit's + * `stageCredentials` returns one); the Workshop hands it back in `commitReconnect()` so the + * ticket it mints activates exactly these credentials and no later stage's. `expiresAt` is the + * staged credentials' expected refreshability expiry, if known (same semantics as `complete()`). + */ + reconnectComplete(stageId: string, expiresAt?: Date): Promise; // Note: If the authorization flow fails, the error can be displayed directly to the user, and // the callback can be discarded. @@ -550,8 +614,11 @@ export interface GatekeeperConnectCallback extends WorkerEntrypoint { credentialsExpired(): Promise; /** - * Called when credentials have been restored (e.g., after a reconnect flow completes). - * `expiresAt` is the new expected refreshability expiration date, if known. + * Called when credentials have been restored without a browser flow (e.g. a token refresh that + * succeeds after an earlier failure was reported via `credentialsExpired()`). A reconnect flow + * that finishes in a browser must call `reconnectComplete()` instead, since credentials + * restored there are not trusted until the handoff is redeemed. `expiresAt` is the new expected + * refreshability expiration date, if known. */ credentialsRestored(expiresAt?: Date): Promise; } @@ -609,16 +676,36 @@ export interface GatekeeperUser extends WorkerEntrypoint { /** * Start the flow to refresh/replace credentials on this account. Returns the URL for the user - * to visit in a new tab to complete re-authentication. When the flow completes, the - * GatekeeperConnectCallback (provided during the original connectAccount() flow) will be - * notified via credentialsRestored(). The existing account Fetcher and all gatekeeper bindings - * created through it continue to work with the new credentials. - * - * SECURITY: As with connectAccount(), the returned URL must include a cryptographic nonce to - * prevent replay attacks. + * to visit in a popup to complete re-authentication. When the flow completes, the gatekeeper + * stages the new credentials, notifies the GatekeeperConnectCallback (provided during the + * original connectAccount() flow) via reconnectComplete(stageId), and renders the returned + * handoff on the final page. The Workshop then calls commitReconnect(stageId), after which the + * existing account Fetcher and all gatekeeper bindings created through it work with the new + * credentials. + * + * SECURITY: As with connectAccount(), the returned URL is a bearer capability that may be opened + * by someone other than the account's owner. The flow must therefore *stage* the new credentials + * rather than write them over the live ones: gadgets already bound to this account read its live + * credentials directly, so a live write would hand them a phished victim's tokens with no + * Workshop-side check in the way. Staged credentials become live only in commitReconnect(). The + * URL must also include a cryptographic nonce to prevent replay. */ reconnect(): Promise<{url: string}>; + /** + * Make the credentials staged under `stageId` by a reconnect()/ensureResources() flow live, + * replacing the account's current credentials. Called by the Workshop once the completing browser + * has been verified as the owner's (see `GatekeeperConnectCallback.reconnectComplete`). Throws if + * nothing is staged, the stage has expired, or the current stage is a different one; the live + * credentials are then left as they were. + * + * SECURITY: Two reconnects can overlap — the owner's, and one a phished victim was tricked into + * finishing, each replacing the stage. Their tickets are redeemed separately, so a commit of + * "whatever is staged" would let the ticket from one flow activate the other's credentials. The + * id ties each ticket to the credentials whose completion minted it. + */ + commitReconnect(stageId: string): Promise; + /** * For vendors that advertise `providesAuth`, returns the account's email address for use as the * user's sign-in identity. The email MUST be verified by the provider (e.g. Google @@ -636,9 +723,12 @@ export interface GatekeeperUser extends WorkerEntrypoint { * on this account, expanding the grant if needed. * * Returns the URL for the user to visit to authorize them, or no URL if nothing was needed. - * Gatekeepers with no grantable resource types should return no URL. + * Gatekeepers with no grantable resource types should return no URL. A returned URL completes + * exactly like reconnect(): staged credentials, reconnectComplete(stageId), then + * commitReconnect(stageId). * - * SECURITY: As with connectAccount(), any returned URL must include a cryptographic nonce. + * SECURITY: As with reconnect(), any returned URL is a bearer capability, so the flow must stage + * the widened grant rather than write it live, and the URL must include a cryptographic nonce. */ ensureResources(resourceUrlPatterns: string[]): Promise<{url?: string}>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1fdda661..7e2b5a5113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -316,6 +319,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -347,6 +353,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -430,6 +439,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -486,6 +498,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -579,6 +594,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -695,6 +713,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -720,6 +741,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -748,6 +772,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -773,6 +800,9 @@ importers: '@gadgets/configurator-ui': specifier: workspace:* version: link:../configurator-ui + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared @@ -835,6 +865,9 @@ importers: '@gadgets/backend-utils': specifier: workspace:* version: link:../backend-utils + '@gadgets/gatekeeper-kit': + specifier: workspace:* + version: link:../gatekeeper-kit '@gadgets/workshop-shared': specifier: workspace:* version: link:../workshop-shared diff --git a/scripts/run-dev-server.ts b/scripts/run-dev-server.ts index 70e285be52..e1e318822d 100644 --- a/scripts/run-dev-server.ts +++ b/scripts/run-dev-server.ts @@ -555,6 +555,15 @@ for (const gk of gatekeepers) { if (process.env[name] !== undefined) config.vars[name] = process.env[name]; } + // Account connect flows post their completion ticket to the Workshop *origin* named here (see + // packages/workshop-backend/src/connect-handoff.ts), so the backend refuses to complete one without + // it. Default to wherever the frontend is served from: Vite in normal dev, the backend itself in + // run-local mode. + if (config.vars.PUBLIC_BASE_URL === undefined) { + config.vars.PUBLIC_BASE_URL = + serveFrontendAssets ? `http://${backendHost}` : "http://localhost:3000"; + } + for (const gk of gatekeepers) { const binding: ServiceBinding = { binding: bindingName(gk), From 94459f2d63c063bfddabe26b378af13a227f0096 Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:20:16 -0500 Subject: [PATCH 06/15] integration-tests: check a deleted workspace's listing over a fresh session (#478) Deleting a workspace schedules its DO's abort about 100ms out (Overseer.scheduleAccessRestart), and the abort severs every session that still has the workspace open: the session's notifyClosed stub is dropped uncalled, which AuthenticatedApiImpl reads as a lost DO and answers by closing the WebSocket. The lifecycle test disposed its workspace stub right after deleteSelf() and kept polling listGadgets() on the same session, so it raced that close and failed intermittently with "Peer closed WebSocket: 3000 RPC session was shut down by disposing the main stub". It now logs in over a new session for the post-deletion check, as a reconnecting client would. Co-authored-by: Claude Fable 5.1 --- .../__tests__/workshop-lifecycle.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/integration-tests/__tests__/workshop-lifecycle.test.ts b/packages/integration-tests/__tests__/workshop-lifecycle.test.ts index 7913ddcee9..4e41020b97 100644 --- a/packages/integration-tests/__tests__/workshop-lifecycle.test.ts +++ b/packages/integration-tests/__tests__/workshop-lifecycle.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, expect, it } from "vitest"; import { type Harness, startHarness } from "../src/harness.js"; import { mockChatCompletion } from "../src/mock-model.js"; import { NetworkInterceptor } from "../src/network-interceptor.js"; -import { connect, nextUsernames, signUp, waitFor } from "../src/rpc-client.js"; +import { connect, logIn, nextUsernames, signUp, waitFor } from "../src/rpc-client.js"; let harness: Harness | undefined; const network = new NetworkInterceptor({ handlers: [mockChatCompletion("Test chat")] }); @@ -33,8 +33,9 @@ function username(): string { } it.concurrent("lists workspace metadata after activity and removes it after deletion", async () => { + const owner = username(); using publicApi = connect(requireHarness().url); - using authenticated = await signUp(publicApi, username()); + using authenticated = await signUp(publicApi, owner); using workspace = await authenticated.newGadget(); const { id } = await workspace.getMetadata(); expect(await authenticated.listGadgets()).not.toContainEqual(expect.objectContaining({ id })); @@ -55,8 +56,16 @@ it.concurrent("lists workspace metadata after activity and removes it after dele await workspace.deleteSelf(); workspace[Symbol.dispose](); + // Deleting schedules the workspace DO's abort about 100ms out (Overseer.scheduleAccessRestart), + // and an abort severs every session that still has the workspace open: the session's + // `notifyClosed` stub is dropped uncalled, which AuthenticatedApiImpl reads as a lost DO and + // answers by closing the WebSocket. The dispose above usually reaches the DO first, but not + // always, so nothing below may depend on `authenticated` surviving. A browser would reconnect + // and log in again; so does this. + using reconnected = connect(requireHarness().url); + using relisted = await logIn(reconnected, owner); await waitFor("the deleted workspace to disappear from the user's list", async () => - (await authenticated.listGadgets()).some(entry => entry.id === id) ? null : true); + (await relisted.listGadgets()).some(entry => entry.id === id) ? null : true); }); it.concurrent("persists an ordered human-only chat without starting an agent", async () => { From 9c1d9c558744293c19e9d79f1e32bb7d8f68e5f5 Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:55:52 -0500 Subject: [PATCH 07/15] Redeem connect handoff tickets from the popup itself; drop the BroadcastChannel transport (#473) * workshop-shared, workshop-backend: redeem connect handoffs from the popup with a per-flow nonce Every flow start (connectAccount, reconnectAccount, ensureAccountResources, startGatekeeperLogin) mints a server-side nonce and returns it with the url. The popup's own /connect/handoff page presents ticket and nonce together: completeConnectHandoff(ticket, nonce) over the popup's session for connects, the public confirmLogin(ticket, nonce) for sign-in, after which the login tab receives the token through LoginAttempt.receive(). Connect flows are kept as hashes in the user DO (pendingConnectFlows, swept by the existing alarm) and must name the ticket's account; the PendingLogin DO is addressed by the nonce's hash. Both records are deleted before the checks, so a ticket or nonce is spent however the redemption goes. The BroadcastChannel envelope constants and LoginAttempt.claim() are gone. --- AGENTS.md | 2 +- docs/connect-handoff.md | 263 +++++++++++++ docs/oauth-signin.md | 41 +- .../__tests__/connect-pages.test.ts | 79 ++-- packages/gatekeeper-kit/src/connect-pages.ts | 77 +--- packages/mcp-shared/__tests__/http.test.ts | 5 +- .../__tests__/connect-handoff.test.ts | 217 ++++++++-- .../__tests__/pending-login.test.ts | 190 ++++++--- .../workshop-backend/__tests__/test-worker.ts | 15 + .../workshop-backend/src/auth/login-flow.ts | 111 ++++-- .../workshop-backend/src/connect-handoff.ts | 35 +- packages/workshop-backend/src/server.ts | 50 ++- packages/workshop-backend/src/user.ts | 97 +++-- .../src/BlueprintLandingPage.test.tsx | 70 +--- .../src/BlueprintLandingPage.tsx | 19 +- .../src/ConnectHandoffListener.tsx | 18 - .../src/ConnectHandoffPage.test.tsx | 279 +++++++++++++ .../src/ConnectHandoffPage.tsx | 123 ++++++ .../workshop-frontend/src/GatekeeperModal.tsx | 16 +- .../src/ObserverConfigModal.test.tsx | 48 ++- .../src/ObserverConfigModal.tsx | 15 +- .../src/OnboardingWizard.tsx | 3 +- .../workshop-frontend/src/ResourcePicker.tsx | 16 +- .../src/components/auth/OAuthButtons.test.tsx | 339 +++++++++++----- .../src/components/auth/OAuthButtons.tsx | 168 ++++---- .../components/billing/OutOfCreditsModal.tsx | 3 +- .../src/components/billing/UsageSettings.tsx | 6 +- .../src/connectHandoff.test.tsx | 370 ++++++------------ .../workshop-frontend/src/connectHandoff.ts | 241 +++++------- .../workshop-frontend/src/rootRoute.test.tsx | 123 ++++++ .../workshop-frontend/src/routeTree.gen.ts | 21 + .../workshop-frontend/src/routes/__root.tsx | 16 +- .../src/routes/connect.handoff.tsx | 6 + .../src/routes/gatekeepers.tsx | 16 +- packages/workshop-shared/src/api.ts | 109 ++++-- packages/workshop-shared/src/gatekeeper.ts | 36 +- 36 files changed, 2106 insertions(+), 1137 deletions(-) create mode 100644 docs/connect-handoff.md delete mode 100644 packages/workshop-frontend/src/ConnectHandoffListener.tsx create mode 100644 packages/workshop-frontend/src/ConnectHandoffPage.test.tsx create mode 100644 packages/workshop-frontend/src/ConnectHandoffPage.tsx create mode 100644 packages/workshop-frontend/src/rootRoute.test.tsx create mode 100644 packages/workshop-frontend/src/routes/connect.handoff.tsx diff --git a/AGENTS.md b/AGENTS.md index 147ac7d621..bca45737c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The project structure is: * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. * packages/gatekeeper-*: Gatekeeper workers for external service integrations. * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens. - * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml` (which posts a single-use ticket back to the Workshop — over a same-origin BroadcastChannel, since connect popups are disowned before navigation, or to its opener for sign-in) and a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone. + * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone. * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page, and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither. * packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README. * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`. diff --git a/docs/connect-handoff.md b/docs/connect-handoff.md new file mode 100644 index 0000000000..43d2a4f838 --- /dev/null +++ b/docs/connect-handoff.md @@ -0,0 +1,263 @@ +# The connect handoff + +How a finished gatekeeper connect, reconnect, ensure-resources or sign-in flow is bound to the +browser that started it. The pieces: the **ticket** (`ConnectHandoff` in +`packages/workshop-shared/src/gatekeeper.ts`), the **nonce** (`ConnectFlowStart` in +`packages/workshop-shared/src/api.ts`), the gatekeeper's completion page (`connectHandoffPageHtml` in +`packages/gatekeeper-kit/src/connect-pages.ts`), the Workshop's `/connect/handoff` page +(`packages/workshop-frontend/src/ConnectHandoffPage.tsx`) and the server side +(`packages/workshop-backend/src/connect-handoff.ts`, `user.ts`, `auth/login-flow.ts`). + +## The threat, and the ticket + +A connect / reconnect / sign-in URL is a bearer capability: whoever opens it can finish the flow, +and nothing in the HTTP requests ties the browser that finishes to the user who started it. An +attacker can therefore start a connect in their own Workshop account and phish a victim into +opening the URL, whereupon the victim's provider credentials would land in the attacker's account +(or, for sign-in, the attacker would receive a session as the victim). + +The defence is that finishing the flow activates nothing. `GatekeeperConnectCallback.complete()` / +`reconnectComplete()` return a `ConnectHandoff = { targetOrigin, ticket }`: + +- `ticket` is a fresh 256-bit secret (`newSecretToken()`), rendered as 64 lowercase hex characters. + Only its SHA-256 hash is stored, and only in the initiating user's Durable Object + (`pendingHandoffs` in `user.ts`, written by `#stagePendingHandoff`) or, for sign-in, in the + `PendingLogin` DO (`deliver()` in `login-flow.ts`). It is single-use and valid for + `PENDING_HANDOFF_LIFETIME_MS` (two minutes). +- `targetOrigin` is the Workshop's origin, from deployment configuration only: + `handoffTargetOrigin(env)` reads `PUBLIC_BASE_URL` and fails closed when it is unset. No request + header is consulted, so nothing a client asserts can route the ticket elsewhere. + +Until the ticket is redeemed the gatekeeper holds credentials that are reachable from no Workshop +account: a connect is staged (`stagePendingConnect`), a reconnect's credentials stay staged in the +gatekeeper under a `stageId` (`stagePendingRestore`), a sign-in's token is parked in `PendingLogin`. +A connect is redeemed over the initiating user's own authenticated session +(`AuthenticatedApi.completeConnectHandoff`, which looks the ticket up in the caller's own DO), and +only that redemption activates the grant. A victim who finishes an attacker's flow ends with a +ticket their own session cannot redeem. + +## The nonce + +The ticket alone binds redemption to the *user*; the nonce binds it to the *popup the Workshop +opened for that flow*. Every flow start (`connectAccount`, `reconnectAccount`, +`ensureAccountResources`, `startGatekeeperLogin`) mints a second `newSecretToken()` server-side and +returns its hex alongside the `url`. The Workshop tab writes it into the **popup's** sessionStorage, +never its own: `openDisownedPopup` in `connectHandoff.ts` opens the popup empty +(`window.open('', name, features)` yields a same-origin `about:blank`, so `popup.sessionStorage` is +writable), sets `opener = null`, stores `{ kind, nonce }` under `HANDOFF_KEY` (`gadgets.handoff`), +and only then navigates the popup with `location.replace(url)`. + +Why the popup's storage: + +- The nonce then exists in exactly two places, the server and that popup. A handoff link opened any + other way — a fresh tab, a pasted URL, a `target=_blank` link from the Workshop, a link an + attacker sends the victim — holds no nonce and redeems nothing. Without it, a public + `confirmLogin(ticket)` would let anyone holding a ticket push a login into the victim's tab with + no click at all, and a public redemption endpoint would be a one-click oracle for whether a + ticket is live. +- sessionStorage is scoped per top-level browsing context and per origin. It survives the popup's + trip through the gatekeeper and the provider (those documents, on other origins, see a different + storage) and is readable again once the popup is back on the Workshop origin. +- Every popup gets a fresh window name (`uniquePopupName('gadgets-connect')` / + `uniquePopupName('gatekeeper-login')`): `window.open('', existingName)` returns an existing window + *without navigating it*, and a popup still parked on a provider page is cross-origin, so the + storage write would throw. The name carries a random suffix (`crypto.randomUUID()`) rather than a + per-document counter: a reload resets a counter while an old disowned popup keeps its name. + `openConnectWindow` closes the previous connect popup this tab holds, best-effort, before opening + the next. + +Server side, for connects: `openConnectFlow(accountId)` in `user.ts` records +`{ nonceHash, accountId, expiresAt }` in `pendingConnectFlows`, alive for `CONNECT_FLOW_LIFETIME_MS` +(30 minutes, sized for the gatekeeper's initiation nonce plus the OAuth nonce plus the handoff +window). `completeConnectHandoff(ticket, nonce)` hashes both (`hashPresentedSecret`; anything but +64 lowercase hex hashes to nothing), then, in this order: reads and deletes the ticket's +`pendingHandoffs` record; reads and deletes the nonce's `pendingConnectFlows` record; checks that +both exist, neither has expired, and `flow.accountId === record.accountId`. The deletes happen +before the checks, under the DO's input gate, so a ticket is spent however the rest goes, a wrong +nonce still spends the ticket, and a nonce cannot be retried against another ticket. A staged connect +the checks reject is dropped like an unredeemed one (`#dropPendingConnect`, which revokes the grant). + +For sign-in the nonce *addresses* the state: `startGatekeeperLogin` names the `PendingLogin` DO +`idFromName(hash of nonce)`, so `confirmLogin(ticket, nonce)` can find the attempt while the login +tab holds only the `attempt` capability and no id at all. + +## Account connect + +1. The tab calls `AuthenticatedApi.connectAccount(vendorId)` (or `reconnectAccount` / + `ensureAccountResources`). The user DO asks the vendor for the flow `url`, mints the nonce with + `openConnectFlow`, and returns `{ url, nonce }`. +2. `openConnectWindow(flow)` opens the disowned popup carrying the nonce and navigates it to `url`. +3. The popup traverses the gatekeeper and the provider. On success the gatekeeper calls + `callback.complete(user)`; `GatekeeperConnectCallbackImpl` (in `user.ts`) forwards to + `stagePendingConnect`, which stores the ticket's hash and returns `{ targetOrigin, ticket }`. +4. The gatekeeper renders `connectHandoffPageHtml(handoff)`, whose script does + `window.location.replace(targetOrigin + "/connect/handoff#" + encodeURIComponent(ticket))`. The + kit validates that `targetOrigin` is exactly an origin and knows nothing else about the handoff; + this is the only document in the flow without an RPC client. +5. `/connect/handoff` is the Workshop SPA: route `src/routes/connect.handoff.tsx`, component + `ConnectHandoffPage`, rendered standalone and header-less by `src/routes/__root.tsx` + (`isHandoff`). The page reads the ticket from the fragment (`ticketFromHandoffFragment`) and the + nonce from its own sessionStorage (`readPopupHandoff`, which removes the record as it reads), + strips the fragment with `history.replaceState`, authenticates its own WebSocket RPC session the + way any Workshop tab does (its own `useAuth`: the shared `localStorage` `authToken`, or the + Cloudflare Access cookie in an Access deployment), and calls `completeConnectHandoff(ticket, + nonce)`. On success it calls `window.close()` and shows + "Connected" for browsers that refuse. +6. The user DO activates the grant (`putConnectedAccount` for a connect; `commitReconnect(stageId)` + plus `markCredentialsRestored` for a restore) and notifies subscribers. The tab that started the + flow learns of the account through `subscribeConnectedAccounts()`, which every screen already + uses; nothing in the tab awaits the redemption. + +```mermaid +sequenceDiagram + participant Tab as Workshop tab + participant WS as Workshop backend (user DO) + participant Popup as Popup + participant GK as Gatekeeper + participant P as Provider + Tab->>WS: connectAccount(vendorId) + WS->>GK: vendor.connectAccount(callback) + GK-->>WS: url + WS-->>Tab: { url, nonce } (openConnectFlow stores hash(nonce), accountId) + Tab->>Popup: window.open('', fresh name) + Tab->>Popup: opener = null + Tab->>Popup: sessionStorage[gadgets.handoff] = { kind: connect, nonce } + Tab->>Popup: location.replace(url) + Popup->>GK: GET url + GK->>P: OAuth consent + P-->>GK: code + GK->>WS: callback.complete(user) + WS-->>GK: { targetOrigin, ticket } (stagePendingConnect stores hash(ticket)) + GK-->>Popup: connectHandoffPageHtml: location.replace(targetOrigin + /connect/handoff#35;ticket) + Popup->>Popup: SPA loads, reads ticket + nonce, strips fragment, authenticates (useAuth) + Popup->>WS: completeConnectHandoff(ticket, nonce) + WS->>WS: delete ticket record, delete flow, check accountId, putConnectedAccount + WS-->>Popup: ok + Popup->>Popup: window.close() + WS-->>Tab: subscribeConnectedAccounts: add(account) +``` + +## Sign-in + +The popup has no session, so the shape differs in who redeems what. + +1. The login tab calls `PublicApi.startGatekeeperLogin(vendorId)`, which mints the nonce, names a + `PendingLogin` DO by its hash, calls `begin()` on it, hands the gatekeeper a + `LoginConnectCallbackImpl`, and returns `{ url, nonce, attempt }`. +2. `OAuthButtons` opens the same disowned popup (`openDisownedPopup(url, + uniquePopupName('gatekeeper-login'), { kind: 'login', nonce })`) and polls `attempt.receive()` + every second (`RECEIVE_POLL_MS`). +3. The gatekeeper calls `complete(user)`. `LoginConnectCallbackImpl` reads the verified email, mints + a session, and parks the `":"` token in the `PendingLogin` DO under the hash of a + fresh ticket (`deliver(token, ticketHash)`); `complete()` returns `{ targetOrigin, ticket }` and + the gatekeeper's final page navigates the popup to `/connect/handoff#` as above. +4. `ConnectHandoffPage` sees `kind: 'login'` and calls `PublicApi.confirmLogin(ticket, nonce)`. The + backend finds the DO by `idFromName(hash(nonce))` and calls `confirm(ticket)`, which marks the + delivered result confirmed if the ticket's hash matches; a wrong ticket throws without touching + the result, so it cannot consume what the right ticket is about to confirm. +5. The login tab's next `receive()` returns the token (and clears the result, so a repeat gets no + second copy). The tab stores it in `localStorage.authToken` and re-authenticates. + +The popup never sees the token: the token is released only to the holder of the `attempt` +capability, which never leaves the login tab. Holding `attempt` alone yields nothing either, since +`receive()` returns null until a popup holding the nonce confirms the ticket. + +```mermaid +sequenceDiagram + participant Tab as Login tab (OAuthButtons) + participant WS as Workshop backend + participant PL as PendingLogin DO + participant Popup as Popup + participant GK as Gatekeeper + Tab->>WS: startGatekeeperLogin(vendorId) + WS->>PL: idFromName(hash(nonce)).begin() + WS-->>Tab: { url, nonce, attempt } + Tab->>Popup: window.open('', fresh name) + Tab->>Popup: opener = null + Tab->>Popup: sessionStorage[gadgets.handoff] = { kind: login, nonce } + Tab->>Popup: location.replace(url) + loop every second + Tab->>PL: attempt.receive() + PL-->>Tab: null + end + Popup->>GK: OAuth flow with the provider + GK->>WS: callback.complete(user) + WS->>PL: deliver(token, hash(ticket)) + WS-->>GK: { targetOrigin, ticket } + GK-->>Popup: connectHandoffPageHtml: location.replace(targetOrigin + /connect/handoff#35;ticket) + Popup->>WS: confirmLogin(ticket, nonce) + WS->>PL: idFromName(hash(nonce)).confirm(ticket) + WS-->>Popup: ok + Popup->>Popup: window.close() + Tab->>PL: attempt.receive() + PL-->>Tab: token (result cleared) + Tab->>Tab: localStorage.authToken = token +``` + +## Why the fragment is safe + +The ticket travels only in the URL fragment. A browser never sends a fragment to a server nor in a +`Referer` header, so it appears in no access log on the way; `location.replace()` leaves no history +entry to revisit; `ConnectHandoffPage` strips it with `history.replaceState` as soon as it has read +it, and its storage record is spent as it is read, so neither a reload nor a re-render can present +the ticket twice; and the ticket is single-use and expires two minutes after the flow finishes. + +The invariant: **the ticket only ever reaches a document on the backend-supplied `targetOrigin`.** +The kit rejects any `targetOrigin` that is not exactly an origin, and the origin itself comes from +`PUBLIC_BASE_URL` alone. + +## Why not postMessage, an opener, or a BroadcastChannel + +A popup that keeps `window.opener` exposes every page in the flow to reverse tabnabbing: any +document the popup passes through — the provider's, or an MCP server the user pasted the URL of — +could navigate the authenticated Workshop tab to a phishing page. So the Workshop disowns the popup +before navigating it, and with no opener there is nothing to `postMessage` to. Providers that +isolate their pages with COOP sever the opener anyway, so a design resting on it would break with +them regardless. + +A same-origin `BroadcastChannel` from the completion page would work only when the gatekeeper is +served from the Workshop's origin, and the popup is already the Workshop SPA with its own session, +so a channel would save one page load in that one deployment shape at the cost of a second transport +to secure and test. The redirect is the single transport, and it works for a gatekeeper on any host. + +## Deployment notes + +- `/connect/handoff` must be served as the SPA directly. A fragment survives an HTTP redirect, but + the page that reads it must be ours: `packages/router` serves the frontend assets with + `not_found_handling: single-page-application`, which covers it. The path literal is pinned by a + test in both `gatekeeper-kit` and `workshop-frontend`. +- The kit and the Workshop deploy together: the kit's page navigates to the Workshop path and the + Workshop's flow starts return the nonce the page needs. The switch is not negotiated, so a + Workshop tab loaded before a deploy that changes the handoff needs a reload before its next + connect: a connect it starts afterwards writes no nonce, and the popup lands on "This link isn't + valid" (whose copy says to reload). An old kit's completion page reaches nobody. Every RPC shape + change in this repo has the same stale-tab window, and there is no reload mechanism for it. +- Each connect costs one SPA load in the popup (the handoff page), with its own WebSocket session. +- A connect completes even if the Workshop tab was closed: the popup redeems the ticket itself, and + the account is in the user's list the next time any tab subscribes. + +## Shared gatekeeper + +Many Workshops can be bound to one gatekeeper. Each Workshop's callback (`GatekeeperConnectCallbackImpl` +or `LoginConnectCallbackImpl`, both Workshop-backend entrypoints) mints the handoff with its own +`handoffTargetOrigin(env)`, so the completion page sends the popup to the Workshop that started the +flow, whichever host the gatekeeper runs on. Open question (Kenton): how the gatekeeper authorizes +which Workshops may bind to it at all. + +## Failure modes the user sees + +| What the user sees | Where the string lives | +| --- | --- | +| "Pop-up blocked. Please allow pop-ups and try again." | `openDisownedPopup` in `connectHandoff.ts`. Sign-in shows it in `OAuthButtons`' error banner; connect call sites log it and toast their own generic title: "Failed to start connection flow" / "Failed to start reconnect flow" (`GatekeeperModal.tsx`, `BlueprintLandingPage.tsx`), "Failed to start connection flow" / "Failed to start re-authentication flow" (`ResourcePicker.tsx`, `ObserverConfigModal.tsx`), "Failed to start connection" (`OnboardingWizard.tsx`, `routes/gatekeepers.tsx`), "Failed to start Cloudflare connection" (`OutOfCreditsModal.tsx`, `UsageSettings.tsx`). | +| "This browser blocks storage in pop-ups, so the flow cannot complete. Allow site data for this site and try again." | `openDisownedPopup` in `connectHandoff.ts`, when the nonce cannot be written into the popup's `sessionStorage`; the popup is closed again and nothing is started, since the flow could never complete. Surfaced like the pop-up-blocked error above. | +| "This link isn't valid" | `INVALID` in `ConnectHandoffPage.tsx`: the fragment holds no ticket, or the popup's storage holds no nonce record (the page was opened some other way, storage is unreadable there, or the Workshop tab predates the deploy that introduced the nonce and wrote none). Shown without a server call; the copy tells the user to reload the Workshop. | +| "You're signed out" | `SIGNED_OUT` in `ConnectHandoffPage.tsx`: a connect popup whose `useAuth` found no `authToken` in `localStorage`. In a Cloudflare Access deployment `useAuth` always holds a pipelined stub, so a lapsed Access identity is rejected server-side and shows as "Could not complete the connection" with the auth error instead. | +| "Could not complete the connection" + server message | `ConnectHandoffPage.tsx`; the message is `completeConnectHandoff`'s, "This connection attempt has expired. Please try again." from `user.ts` for an unknown, spent or expired ticket or nonce, or a mismatched pair. A redemption that failed because the popup's RPC connection dropped is presented again once the session reconnects (`main.tsx` publishes one replacement stub per outage, on which the page's `useAuth` re-authenticates); while the connection is down the page shows "Finishing up…" instead of the transport error. The retry is safe because ticket and nonce are single-use: a repeat of a call that did land is refused as expired. | +| "Could not sign in" + server message | `ConnectHandoffPage.tsx`; the message is `EXPIRED_MESSAGE` from `login-flow.ts` ("This sign-in attempt has expired. Please try again.") or the reason `LoginConnectCallbackImpl` recorded with `PendingLogin.fail()` (no verified email, sign-ups disabled, "Sign-in failed. Please try again."). `PendingLogin.#result()` clears an expired or failed result as it reports it, so the reason goes to whichever of the popup's `confirmLogin()` or the login tab's `receive()` reads first, and the other surface (`OAuthButtons`' error banner in the tab, or the popup) shows `EXPIRED_MESSAGE`. | + +Expiry sweeps run without the user: the user DO's `alarm()` drops a staged connect whose ticket did +not come back within `PENDING_HANDOFF_LIFETIME_MS` (revoking the grant via `#dropPendingConnect`) +and a flow whose nonce was never presented within `CONNECT_FLOW_LIFETIME_MS` (nothing to revoke); +the `PendingLogin` alarm wipes an unreceived login result after `PENDING_HANDOFF_LIFETIME_MS`, or an +attempt the gatekeeper never delivered to after `LOGIN_PENDING_LIFETIME_MS`, which is +`CONNECT_FLOW_LIFETIME_MS`: one budget for every flow that ends on the handoff page. diff --git a/docs/oauth-signin.md b/docs/oauth-signin.md index 37f66b5e0c..a40e190165 100644 --- a/docs/oauth-signin.md +++ b/docs/oauth-signin.md @@ -34,21 +34,28 @@ what persists a usable connected account. `GatekeeperVendor.connectAccount` take ## Sign-in flow -1. The client calls `PublicApi.startGatekeeperLogin(vendorId)`. The backend creates a short-lived - `PendingLogin` DO, hands the gatekeeper a `LoginConnectCallbackImpl`, and returns the gatekeeper's - OAuth `url` plus an `attempt` stub (a capability wrapping the `PendingLogin` DO — no login id is - exposed to the client). -2. The client opens `url` as a pop-up, keeping itself as the pop-up's opener. +1. The client calls `PublicApi.startGatekeeperLogin(vendorId)`. The backend mints a per-flow + nonce, creates a short-lived `PendingLogin` DO named by the nonce's hash, hands the gatekeeper a + `LoginConnectCallbackImpl`, and returns the gatekeeper's OAuth `url`, the `nonce`, and an + `attempt` stub (a capability wrapping the `PendingLogin` DO — no login id is exposed to the + client). The handoff that follows is the same one every connect flow uses; see + [connect-handoff.md](connect-handoff.md). +2. The client opens `url` as a disowned pop-up, writing the nonce into the pop-up's own + sessionStorage before navigating it (`openDisownedPopup` in `connectHandoff.ts`). No page in the + flow ever holds `window.opener`. 3. When the gatekeeper finishes, it calls `complete(user)`. The callback reads `user.getAuthenticatedEmail()`, resolves/creates the email-keyed `UserDurableObject`, mints a session, and parks the `":"` token in the `PendingLogin` DO under the hash of a - fresh handoff ticket. `complete()` returns that ticket, and the gatekeeper's final page posts it to - its opener — exactly as the connect-account flow does (`connectHandoffPageHtml` in gatekeeper-kit). -4. The opener calls `attempt.claim(ticket)`; the `PendingLogin` DO releases the token only for the - matching ticket, once. This is what binds the session to the browser that started the attempt: - the sign-in URL is a bearer capability, so whoever holds `attempt` without the ticket (an attacker - who phished a victim into finishing the flow) gets nothing, and the unclaimed token is wiped after - two minutes. + fresh handoff ticket. `complete()` returns that ticket, and the gatekeeper's final page + (`connectHandoffPageHtml` in gatekeeper-kit) navigates the pop-up to the Workshop's + `/connect/handoff` page with the ticket in the URL fragment. +4. That page calls `PublicApi.confirmLogin(ticket, nonce)`, which finds the `PendingLogin` DO by the + nonce's hash and marks the delivered token confirmed if the ticket matches. The login tab polls + `attempt.receive()`, which releases the token only once it is confirmed, once. This is what binds + the session to the browser that started the attempt: the sign-in URL is a bearer capability, so + whoever holds `attempt` without the pop-up holding the nonce (an attacker who phished a victim + into finishing the flow) gets nothing, and the unreceived token is wiped after two minutes. The + pop-up never sees the token. 5. The client stores the token and authenticates as usual. Sign-in does **not** persist a connected account: the minimal-scope grant is only used to read the @@ -79,9 +86,10 @@ In local dev, `run-dev-server.ts` seeds each gatekeeper's `CLIENT_ID`/`CLIENT_SE ## Storage / bindings - `PendingLogin` (DO) — short-lived bridge between a gatekeeper login pop-up and the browser that - started the attempt, reached via `ctx.exports` (no explicit binding). Stores the delivered token - under the ticket's hash until `claim()` consumes it; an alarm wipes an unclaimed result after two - minutes. + started the attempt, reached via `ctx.exports` (no explicit binding) and named by the hash of the + attempt's nonce. Stores the delivered token under the ticket's hash until the pop-up's + `confirmLogin()` confirms it and the login tab's `receive()` consumes it; an alarm wipes an + unreceived result after two minutes. ## Code layout @@ -93,4 +101,5 @@ auth/ ``` Client-side: `ServerConfigContext` exposes `authVendors` and `passwordAuthEnabled`; -`components/auth/OAuthButtons` renders the sign-in options (pop-up + handoff ticket + `attempt.claim()`). +`components/auth/OAuthButtons` renders the sign-in options (disowned pop-up carrying the nonce, polled +`attempt.receive()`); `ConnectHandoffPage` is the pop-up's landing page (`confirmLogin(ticket, nonce)`). diff --git a/packages/gatekeeper-kit/__tests__/connect-pages.test.ts b/packages/gatekeeper-kit/__tests__/connect-pages.test.ts index 6c768017ad..42fca66812 100644 --- a/packages/gatekeeper-kit/__tests__/connect-pages.test.ts +++ b/packages/gatekeeper-kit/__tests__/connect-pages.test.ts @@ -1,7 +1,4 @@ import { describe, expect, it } from "vitest"; -import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, -} from "@gadgets/workshop-shared/gatekeeper"; import { connectHandoffPageHtml, connectMutationError, @@ -50,55 +47,25 @@ describe("connect pages", () => { }); describe("connectHandoffPageHtml", () => { - // Pulls the envelope and target origin the page's script posts out of its two literals. - function postMessageArgs(html: string): [unknown, string] { - const envelope = /var envelope = (.*);\n/.exec(html); + // Pulls the ticket and target origin the page's script navigates with out of its two literals. + function scriptLiterals(html: string): [string, string] { + const ticket = /var ticket = (".*?");\n/.exec(html); const target = /var target = (".*?");\n/.exec(html); - expect(envelope).not.toBeNull(); + expect(ticket).not.toBeNull(); expect(target).not.toBeNull(); // The literals are JSON with `<`, `>` and `&` written as \uXXXX escapes, which JSON accepts. - return [JSON.parse(envelope![1]), JSON.parse(target![1])]; + return [JSON.parse(ticket![1]), JSON.parse(target![1])]; } - it("posts the versioned envelope to exactly the Workshop origin", () => { + it("navigates the popup to the Workshop's handoff page with the ticket in the fragment", () => { const html = connectHandoffPageHtml(HANDOFF); - const [envelope, target] = postMessageArgs(html); + const [ticket, target] = scriptLiterals(html); - expect(envelope).toEqual({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: HANDOFF.ticket }); + expect(ticket).toBe(HANDOFF.ticket); expect(target).toBe("https://workshop.example"); - expect(html).toContain("opener.postMessage(envelope, target)"); - }); - - it("falls back to a same-origin BroadcastChannel named after the message type", () => { - // A disowned connect popup has no opener; only when the page is on the Workshop's own origin - // may it broadcast, and the channel name is the versioned message type so the listener and the - // page cannot drift apart. - const html = connectHandoffPageHtml(HANDOFF); - - expect(html).toContain( - `else if (window.location.origin === target && "BroadcastChannel" in window)`); + // The path is pinned here and in workshop-frontend's route: the kit must not depend on it. expect(html).toContain( - `var channel = new BroadcastChannel(${JSON.stringify(CONNECT_HANDOFF_MESSAGE_TYPE)});`); - expect(html).toContain("channel.postMessage(envelope);"); - // The opener wins when there is one: sign-in and the dev server rely on it. - expect(html.indexOf("opener.postMessage")).toBeLessThan(html.indexOf("new BroadcastChannel")); - }); - - it("repeats a broadcast until the Workshop acknowledges this ticket, then closes", () => { - // A Workshop tab whose session is mid-reconnect misses a one-shot broadcast, and the connect - // would fail silently. The ticket is single-use server-side, so repeating it is safe; the ack - // for this ticket is what ends the repeats. - const html = connectHandoffPageHtml(HANDOFF); - - expect(html).toContain("setInterval(function () { channel.postMessage(envelope); }, 1000)"); - expect(html).toContain(`e.data.type === ${JSON.stringify(CONNECT_HANDOFF_ACK_MESSAGE_TYPE)}`); - expect(html).toContain("e.data.ticket === envelope.ticket"); - // Gives up after 30 s with the "couldn't reach" text rather than closing on a timer: the - // channel branch returns before the 2 s fallback close, which is for the opener branch only. - expect(html).toContain("setTimeout(function () { clearInterval(repeat); unreachable(); }, 30000)"); - const channelBranch = html.slice(html.indexOf("var channel"), html.indexOf("} else {")); - expect(channelBranch).toContain("return;"); - expect(channelBranch).not.toContain("2000"); + `window.location.replace(target + "/connect/handoff#" + encodeURIComponent(ticket))`); }); it("cannot be broken out of by the ticket or origin it embeds", () => { @@ -108,12 +75,12 @@ describe("connectHandoffPageHtml", () => { expect(html).not.toContain("")).toHaveLength(2); expect(html.split("")).toHaveLength(2); - expect(postMessageArgs(html)[0]).toEqual({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: hostile.ticket }); + expect(scriptLiterals(html)[0]).toBe(hostile.ticket); }); it("refuses a targetOrigin that is not exactly an origin", () => { - // A path or trailing slash would make the browser drop the message; an unparsable value or an - // opaque origin would be far worse — `postMessage(…, "*")` style delivery to anyone. + // A trailing slash or path would produce a malformed redirect; an unparsable value or an opaque + // origin would send the ticket somewhere else. for (const targetOrigin of [ "https://workshop.example/", "https://workshop.example/app", "*", "null", "workshop.example", "", "javascript:alert(1)", @@ -125,17 +92,19 @@ describe("connectHandoffPageHtml", () => { .not.toThrow(); }); - it("tells the user when it can reach no Workshop, and only closes when it could", () => { + it("keeps the referrer policy that hides the path from cross-origin requests", () => { + expect(connectHandoffPageHtml(HANDOFF)) + .toContain(``); + }); + + it("carries no channel, opener or message transport", () => { const html = connectHandoffPageHtml(HANDOFF); - expect(html).toContain("if (opener && !opener.closed)"); - expect(html).toContain("setTimeout(function () { window.close(); }, 2000)"); - // The "couldn't reach" branch returns before the close timer, so the message stays readable. - expect(html.lastIndexOf("return;")).toBeLessThan( - html.indexOf("setTimeout(function () { window.close(); }, 2000)")); - expect(html).toContain("couldn't reach the Workshop"); - expect(html).toContain("start the connection again"); - expect(html).toContain(``); + for (const transport of [ + "BroadcastChannel", "opener", "postMessage", "setTimeout", "setInterval", + ]) { + expect(html).not.toContain(transport); + } }); }); diff --git a/packages/gatekeeper-kit/src/connect-pages.ts b/packages/gatekeeper-kit/src/connect-pages.ts index 723dd59174..2698c16e46 100644 --- a/packages/gatekeeper-kit/src/connect-pages.ts +++ b/packages/gatekeeper-kit/src/connect-pages.ts @@ -1,10 +1,6 @@ /** Hardened HTML and browser request guards for gatekeeper connect flows. */ -import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, - CONNECT_HANDOFF_MESSAGE_TYPE, - type ConnectHandoff, -} from "@gadgets/workshop-shared/gatekeeper"; +import type { ConnectHandoff } from "@gadgets/workshop-shared/gatekeeper"; const HTML_ESCAPES: Readonly> = { "&": "&", @@ -138,27 +134,23 @@ function scriptLiteral(value: unknown): string { } /** - * The page a connect flow lands on when it has finished. It delivers the handoff ticket to the - * Workshop and closes itself, over one of two transports: + * The page a finished connect, reconnect or sign-in flow lands on. It navigates the popup to the + * Workshop's own handoff page, `/connect/handoff#`, which redeems the ticket + * over the popup's own session; this page holds no session and needs no RPC client. * - * - `postMessage` to the window that opened it — and *only* to `handoff.targetOrigin`, the - * Workshop's origin, so a browser drops the message if the opener is anyone else. This is the - * sign-in path (the login page keeps the popup handle) and the dev-server path, where the Workshop - * is on another origin. - * - A `BroadcastChannel` named `CONNECT_HANDOFF_MESSAGE_TYPE`, when this page is itself on the - * Workshop's origin and has no opener. The Workshop disowns connect popups before navigating them - * (so no provider page ever holds a handle to the Workshop window), and a same-origin channel is - * the only thing a disowned popup can still reach; the browser scopes it to that origin. The - * envelope is repeated every second until a Workshop tab answers with a - * `CONNECT_HANDOFF_ACK_MESSAGE_TYPE` envelope for this ticket (a tab whose session is - * mid-reconnect would miss a one-shot broadcast, and the connect would fail silently); the ticket - * is single-use server-side, so the repeats are harmless. After 30 seconds unacknowledged the - * page gives up and tells the user, as below. - * - * Without either it can reach no Workshop, so it tells the user to go back and start again; a flow - * opened from a phished link on another origin ends here with its ticket unredeemed. The connection - * itself is inert until the Workshop redeems the ticket on the initiating user's session (see + * The ticket travels in the URL fragment, which a browser never sends to a server nor in a + * `Referer` header, and `location.replace()` leaves no history entry to revisit; the ticket is + * single-use and expires two minutes after the flow finishes. The destination is the + * backend-supplied `targetOrigin`, validated to be exactly an origin, so the ticket reaches no + * other document. A flow finished by anyone but its starter ends with a ticket that person's + * session cannot redeem and no popup of the starter's carries. The connection itself is inert until + * the Workshop redeems the ticket on the initiating user's session (see * `GatekeeperVendor.connectAccount`). + * + * The Workshop must serve `/connect/handoff` directly: a fragment survives an HTTP redirect, but the + * page it lands on has to be the SPA. The path literal is duplicated here on purpose — the kit is + * published to gatekeepers and must not depend on `workshop-frontend` — and each package pins it + * with a test. * @param handoff The handoff returned by `GatekeeperConnectCallback.complete()` / * `reconnectComplete()`. Its `targetOrigin` must be exactly an origin. * @returns Escaped HTML; serve it with `htmlResponse()`. @@ -179,47 +171,18 @@ export function connectHandoffPageHtml(handoff: ConnectHandoff): string { if (origin === "" || origin === "null" || origin !== handoff.targetOrigin) { throw new Error("The connect handoff's targetOrigin is not an origin."); } - const envelope = { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: handoff.ticket }; return ` Connected -

Connected

-

Returning to the Workshop…

+

Connected

+

Returning to the Workshop…

`; } diff --git a/packages/mcp-shared/__tests__/http.test.ts b/packages/mcp-shared/__tests__/http.test.ts index 1a583f773b..fdd2f58609 100644 --- a/packages/mcp-shared/__tests__/http.test.ts +++ b/packages/mcp-shared/__tests__/http.test.ts @@ -64,10 +64,11 @@ describe("handleMcpHttpRequest", () => { // The page carries the ticket, so it must never be cached or framed. expect(response.headers.get("Cache-Control")).toBe("no-store"); expect(response.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'"); - // The page hands the ticket to the Workshop window that opened the flow, and nobody else. + // The page sends the popup, ticket in the fragment, to the Workshop's own handoff page, and + // nowhere else. const html = await response.text(); expect(html).toContain(HANDOFF.ticket); - expect(html).toContain(`postMessage(`); + expect(html).toContain(`window.location.replace(target + "/connect/handoff#"`); expect(html).toContain(`"https://workshop.example"`); }); diff --git a/packages/workshop-backend/__tests__/connect-handoff.test.ts b/packages/workshop-backend/__tests__/connect-handoff.test.ts index 792919acb3..a72ca2b707 100644 --- a/packages/workshop-backend/__tests__/connect-handoff.test.ts +++ b/packages/workshop-backend/__tests__/connect-handoff.test.ts @@ -3,7 +3,9 @@ import { env } from "cloudflare:workers"; import { runInDurableObject } from "cloudflare:test"; import type { GatekeeperUser } from "@gadgets/workshop-shared/gatekeeper"; import type { GatekeeperConnectCallbackImpl, UserDurableObject } from "../src/user.js"; -import { handoffTargetOrigin, PENDING_HANDOFF_LIFETIME_MS } from "../src/connect-handoff.js"; +import { + CONNECT_FLOW_LIFETIME_MS, handoffTargetOrigin, hashSecret, PENDING_HANDOFF_LIFETIME_MS, +} from "../src/connect-handoff.js"; import type { FakeGatekeeperAccount } from "./test-worker.js"; declare module "cloudflare:workers" { @@ -15,11 +17,15 @@ declare module "cloudflare:workers" { const TARGET = "https://workshop.example"; const EXPIRED = "This connection attempt has expired. Please try again."; +// A collection of records that expire, as a test ages or counts them. +type Expiring = { list(): Iterable; put(record: unknown): void }; + // What a test reaches into the user DO for: the typed collections behind the public methods. type UserInternals = UserDurableObject & { storage: { connectedAccounts: { get(id: number): Record | undefined; put(record: unknown): void }; - pendingHandoffs: { list(): Iterable<{ expiresAt: Date }>; put(record: unknown): void }; + pendingHandoffs: Expiring<{ expiresAt: Date }>; + pendingConnectFlows: Expiring<{ nonceHash: string; accountId: number; expiresAt: Date }>; nextAccountId: { get(): number; put(n: number): void }; }; ctx: DurableObjectState & { @@ -54,11 +60,12 @@ function fakeAccount(user: UserInternals, name: string, failing?: Omit, ticket: string): Promise { +// Redeems over the user's stub the way the popup's page does, reporting the outcome as a value: a +// native RPC promise left to `.rejects` is also flagged as an unhandled rejection by the pool. +async function redeem(stub: DurableObjectStub, ticket: string, nonce: string) + : Promise { try { - await stub.completeConnectHandoff(ticket); + await stub.completeConnectHandoff(ticket, nonce); return "ok"; } catch (err) { return (err as Error).message; @@ -69,40 +76,52 @@ function pendingCount(user: UserInternals) { return [...user.storage.pendingHandoffs.list()].length; } -// Age every pending record past its lifetime, as the alarm would find them. -function expirePending(user: UserInternals) { +function flowCount(user: UserInternals) { + return [...user.storage.pendingConnectFlows.list()].length; +} + +// Age every record of a collection past its lifetime, as the alarm would find them. +function expireAll(records: Expiring<{ expiresAt: Date }>) { // Snapshot first: a put during kv.list() invalidates the iterator. - const records = Array.from(user.storage.pendingHandoffs.list()); - for (const record of records) { - user.storage.pendingHandoffs.put({ ...record, expiresAt: new Date(Date.now() - 1) }); + for (const record of Array.from(records.list())) { + records.put({ ...record, expiresAt: new Date(Date.now() - 1) }); } } describe("connect handoff", () => { it("stages a connect and activates it only when its ticket is redeemed", async () => { const { stub, inDo } = freshUser(); - const handoff = await inDo(async user => { + const { handoff, nonce } = await inDo(async user => { const { account } = fakeAccount(user, "octocat"); user.storage.nextAccountId.put(1); + // The flow opens when the popup does, well before the gatekeeper finishes. + const opened = await user.openConnectFlow(0); + const [flow] = Array.from(user.storage.pendingConnectFlows.list()); + expect(flow).toMatchObject({ accountId: 0, nonceHash: await hashSecret(Uint8Array.fromHex(opened)) }); + expect(flow.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(CONNECT_FLOW_LIFETIME_MS); + expect(await user.ctx.storage.getAlarm()).toBe(flow.expiresAt.getTime()); + const staged = await user.stagePendingConnect(0, account, "github", new Date("2027-01-01")); expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); const [pending] = Array.from(user.storage.pendingHandoffs.list()); expect(pending).toBeDefined(); expect(pendingCount(user)).toBe(1); - // The sweep is armed for the record's expiry, which is within the lifetime. + // The sweep is armed for the soonest expiry: the record's, which is within the lifetime. expect(await user.ctx.storage.getAlarm()).toBe(pending.expiresAt.getTime()); expect(pending.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(PENDING_HANDOFF_LIFETIME_MS); - // Only the ticket's hash is at rest. + // Only the ticket's and the nonce's hashes are at rest. for (const [, value] of user.ctx.storage.kv.list()) { expect(JSON.stringify(value)).not.toContain(staged.ticket); + expect(JSON.stringify(value)).not.toContain(opened); } - return staged; + return { handoff: staged, nonce: opened }; }); expect(handoff.targetOrigin).toBe(TARGET); expect(handoff.ticket).toMatch(/^[0-9a-f]{64}$/); + expect(nonce).toMatch(/^[0-9a-f]{64}$/); - // Redeemed the way the browser does it: over the user's own stub. - await stub.completeConnectHandoff(handoff.ticket); + // Redeemed the way the popup's page does it: over the user's own stub, with the flow's nonce. + await stub.completeConnectHandoff(handoff.ticket, nonce); await inDo(async user => { expect(user.storage.connectedAccounts.get(0)).toMatchObject({ id: 0, vendorId: "github", description: { displayName: "octocat" }, @@ -110,6 +129,7 @@ describe("connect handoff", () => { }); expect(await fakeAccount(user, "octocat").calls()).toEqual(["describe"]); expect(pendingCount(user)).toBe(0); + expect(flowCount(user)).toBe(0); }); }); @@ -117,34 +137,39 @@ describe("connect handoff", () => { const { stub, inDo } = freshUser(); const { ticket } = await inDo(user => user.stagePendingConnect(0, fakeAccount(user, "a").account, "github")); + const nonce = await stub.openConnectFlow(0); - expect(await redeem(stub, "f".repeat(64))).toBe(EXPIRED); - expect(await redeem(stub, "not-a-ticket")).toBe(EXPIRED); - expect(await redeem(stub, ticket.toUpperCase())).toBe(EXPIRED); - // The victim's session: a different user's DO knows nothing of the attacker's ticket. - expect(await redeem(freshUser().stub, ticket)).toBe(EXPIRED); - // A failed redemption leaves the ticket redeemable by the right user... - expect(await redeem(stub, ticket)).toBe("ok"); + // A nonce that is nobody's, so these spend neither the ticket nor the flow. + const unknownNonce = "e".repeat(64); + expect(await redeem(stub, "f".repeat(64), unknownNonce)).toBe(EXPIRED); + expect(await redeem(stub, "not-a-ticket", unknownNonce)).toBe(EXPIRED); + expect(await redeem(stub, ticket.toUpperCase(), unknownNonce)).toBe(EXPIRED); + // The victim's session: a different user's DO knows nothing of the attacker's ticket or nonce. + expect(await redeem(freshUser().stub, ticket, nonce)).toBe(EXPIRED); + // A redemption that found neither record leaves the pair redeemable by the right user... + expect(await redeem(stub, ticket, nonce)).toBe("ok"); // ...exactly once. - expect(await redeem(stub, ticket)).toBe(EXPIRED); + expect(await redeem(stub, ticket, nonce)).toBe(EXPIRED); }); it("refuses an expired ticket, revoking the unconfirmed grant whether redeemed or swept", async () => { const { stub, inDo } = freshUser(); const { ticket } = await inDo(user => user.stagePendingConnect(0, fakeAccount(user, "expired").account, "github")); + const nonce = await stub.openConnectFlow(0); await inDo(async user => { await user.stagePendingConnect(1, fakeAccount(user, "swept").account, "github"); - expirePending(user); + expireAll(user.storage.pendingHandoffs); }); - expect(await redeem(stub, ticket)).toBe(EXPIRED); + expect(await redeem(stub, ticket, nonce)).toBe(EXPIRED); await inDo(async user => { expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); // The refused redemption consumed its record — and revoked the grant it could no longer // activate, which the alarm would otherwise never see; the alarm sweeps the other. expect(await fakeAccount(user, "expired").calls()).toEqual(["describe", "revoke"]); expect(pendingCount(user)).toBe(1); + expect(flowCount(user)).toBe(0); await user.alarm(); expect(pendingCount(user)).toBe(0); expect(await fakeAccount(user, "swept").calls()).toEqual(["describe", "revoke"]); @@ -164,8 +189,9 @@ describe("connect handoff", () => { }); return user.stagePendingConnect(1, fakeAccount(user, "dup", { failRevoke: true }).account, "github"); }); + const nonce = await stub.openConnectFlow(1); - expect(await redeem(stub, ticket)).toBe("revoke failed"); + expect(await redeem(stub, ticket, nonce)).toBe("revoke failed"); await inDo(async user => { expect(user.storage.connectedAccounts.get(1)).toBeUndefined(); expect(pendingCount(user)).toBe(0); @@ -173,7 +199,7 @@ describe("connect handoff", () => { expect(await fakeAccount(user, "dup").calls()).toEqual(["describe", "revoke", "revoke"]); }); // The ticket was consumed by the attempt. - expect(await redeem(stub, ticket)).toBe(EXPIRED); + expect(await redeem(stub, ticket, nonce)).toBe(EXPIRED); }); it("commits a staged reconnect and then marks the credentials restored", async () => { @@ -190,8 +216,9 @@ describe("connect handoff", () => { expect(user.storage.connectedAccounts.get(0)?.credentialsExpired).toBe(true); return handoff; }); + const nonce = await stub.openConnectFlow(0); - await stub.completeConnectHandoff(ticket); + await stub.completeConnectHandoff(ticket, nonce); await inDo(async user => { // The commit names the stage this ticket was minted for, not "whatever is staged". expect(await fakeAccount(user, "renewed").calls()) @@ -213,10 +240,11 @@ describe("connect handoff", () => { }); return user.stagePendingRestore(0, STAGE_ID, new Date("2027-06-01")); }); + const nonce = await stub.openConnectFlow(0); // The credentials went live at the commit; a failed describe() must not leave the account // showing as expired, which would send the user back through a reconnect that changes nothing. - expect(await redeem(stub, ticket)).toBe("ok"); + expect(await redeem(stub, ticket, nonce)).toBe("ok"); await inDo(async user => { expect(await fakeAccount(user, "stale").calls()) .toEqual([`commitReconnect(${STAGE_ID})`, "describe"]); @@ -233,18 +261,19 @@ describe("connect handoff", () => { // rather than failing the alarm forever. const { stub, inDo } = freshUser(); const before = Date.now(); - const { ticket } = await inDo(async user => { + const { ticket, nonce } = await inDo(async user => { user.ctx.storage.kv.put(`pendingHandoffs:${"0".repeat(64)}`, null); expect(() => pendingCount(user)).toThrow(); + const opened = await user.openConnectFlow(0); const staged = await user.stagePendingConnect(0, fakeAccount(user, "listable").account, "github"); const alarm = await user.ctx.storage.getAlarm(); expect(alarm).toBeGreaterThanOrEqual(before + PENDING_HANDOFF_LIFETIME_MS); await user.alarm(); expect(await user.ctx.storage.getAlarm()).toBeGreaterThanOrEqual(before + PENDING_HANDOFF_LIFETIME_MS); - return staged; + return { ticket: staged.ticket, nonce: opened }; }); - expect(await redeem(stub, ticket)).toBe("ok"); + expect(await redeem(stub, ticket, nonce)).toBe("ok"); await inDo(async user => { expect(user.storage.connectedAccounts.get(0)?.vendorId).toBe("github"); }); @@ -259,7 +288,7 @@ describe("connect handoff", () => { description: { displayName: "live" }, }); await user.stagePendingRestore(0, STAGE_ID); - expirePending(user); + expireAll(user.storage.pendingHandoffs); await user.alarm(); expect(pendingCount(user)).toBe(0); expect(await fakeAccount(user, "live").calls()).toEqual([]); @@ -267,12 +296,128 @@ describe("connect handoff", () => { }); }); + it("rejects another flow's nonce, spending the ticket and the nonce alike", async () => { + // Two flows for two accounts, each finished. A ticket presented with the nonce of the other + // flow — equally, the right nonce paired with the other account's ticket — is refused. + const { stub, inDo } = freshUser(); + const nonceA = await stub.openConnectFlow(0); + const nonceB = await stub.openConnectFlow(1); + const [ticketA, ticketB] = await inDo(async user => { + user.storage.nextAccountId.put(2); + const a = await user.stagePendingConnect(0, fakeAccount(user, "flow-a").account, "github"); + const b = await user.stagePendingConnect(1, fakeAccount(user, "flow-b").account, "github"); + return [a.ticket, b.ticket]; + }); + + expect(await redeem(stub, ticketA, nonceB)).toBe(EXPIRED); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); + // The refused connect is revoked like an expired one, and both records it touched are spent. + expect(await fakeAccount(user, "flow-a").calls()).toEqual(["describe", "revoke"]); + expect(pendingCount(user)).toBe(1); + expect(flowCount(user)).toBe(1); + }); + // A's ticket is gone, so its own nonce redeems nothing (and is spent by the try)... + expect(await redeem(stub, ticketA, nonceA)).toBe(EXPIRED); + // ...and B's nonce is gone, so B's own ticket cannot be redeemed with it. + expect(await redeem(stub, ticketB, nonceB)).toBe(EXPIRED); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(1)).toBeUndefined(); + expect(await fakeAccount(user, "flow-b").calls()).toEqual(["describe", "revoke"]); + expect(pendingCount(user)).toBe(0); + expect(flowCount(user)).toBe(0); + }); + }); + + it("rejects a malformed or expired nonce, spending the ticket", async () => { + const { stub, inDo } = freshUser(); + const { ticket } = await inDo(user => + user.stagePendingConnect(0, fakeAccount(user, "malformed").account, "github")); + const nonce = await stub.openConnectFlow(0); + expect(await redeem(stub, ticket, "not-a-nonce")).toBe(EXPIRED); + // A wrong nonce still spends the ticket, so the right one arrives too late. + expect(await redeem(stub, ticket, nonce)).toBe(EXPIRED); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(0)).toBeUndefined(); + expect(await fakeAccount(user, "malformed").calls()).toEqual(["describe", "revoke"]); + expect(pendingCount(user)).toBe(0); + expect(flowCount(user)).toBe(0); + }); + + const stale = await stub.openConnectFlow(1); + const { ticket: lateTicket } = await inDo(async user => { + user.storage.nextAccountId.put(2); + expireAll(user.storage.pendingConnectFlows); + return user.stagePendingConnect(1, fakeAccount(user, "stale-flow").account, "github"); + }); + expect(await redeem(stub, lateTicket, stale)).toBe(EXPIRED); + await inDo(async user => { + expect(user.storage.connectedAccounts.get(1)).toBeUndefined(); + expect(await fakeAccount(user, "stale-flow").calls()).toEqual(["describe", "revoke"]); + expect(pendingCount(user)).toBe(0); + expect(flowCount(user)).toBe(0); + }); + }); + + it("sweeps expired flows from the alarm and leaves live ones", async () => { + const { inDo } = freshUser(); + await inDo(async user => { + const stale = await user.openConnectFlow(0); + await user.openConnectFlow(1); + const staleHash = await hashSecret(Uint8Array.fromHex(stale)); + const flows = Array.from(user.storage.pendingConnectFlows.list()); + const staleFlow = flows.find(flow => flow.nonceHash === staleHash); + expect(staleFlow).toMatchObject({ accountId: 0 }); + user.storage.pendingConnectFlows.put({ ...staleFlow, expiresAt: new Date(Date.now() - 1) }); + + await user.alarm(); + const [live] = Array.from(user.storage.pendingConnectFlows.list()); + expect(flowCount(user)).toBe(1); + expect(live.accountId).toBe(1); + expect(await user.ctx.storage.getAlarm()).toBe(live.expiresAt.getTime()); + }); + }); + it("derives the target origin from PUBLIC_BASE_URL only, failing closed without it", () => { expect(handoffTargetOrigin({ PUBLIC_BASE_URL: `${TARGET}/some/path` } as Cloudflare.Env)) .toBe(TARGET); expect(() => handoffTargetOrigin({} as Cloudflare.Env)).toThrow("PUBLIC_BASE_URL"); }); + it("pairs the nonce a reconnect or expansion returns with that account's ticket", async () => { + // The pairing the popup relies on: the flow opened alongside the url names the account whose + // ticket the gatekeeper will later stage. (connectAccount pairs the same way for the id it + // reserves; it needs a vendor binding, so it is not driven here.) + const { stub, inDo } = freshUser(); + await inDo(async user => { + user.storage.nextAccountId.put(2); + for (const id of [0, 1]) { + user.storage.connectedAccounts.put({ + id, account: fakeAccount(user, `acct-${id}`).account, vendorId: "github", + description: { displayName: `acct-${id}`, uniqueName: `acct-${id}` }, + }); + } + }); + + const reconnect = await stub.reconnectAccount(1); + expect(reconnect.url).toBe("https://gk.example/reconnect/acct-1"); + const restore = await stub.stagePendingRestore(1, STAGE_ID); + expect(await redeem(stub, restore.ticket, reconnect.nonce)).toBe("ok"); + + expect(await stub.ensureAccountResources(0, [])).toBeNull(); + const expansion = await stub.ensureAccountResources(0, ["https://api.github.com/repos/*"]); + expect(expansion?.url).toBe("https://gk.example/expand/acct-0"); + const expanded = await stub.stagePendingRestore(0, STAGE_ID); + expect(await redeem(stub, expanded.ticket, expansion!.nonce)).toBe("ok"); + await inDo(async user => { + expect(await fakeAccount(user, "acct-1").calls()).toContain(`commitReconnect(${STAGE_ID})`); + expect(await fakeAccount(user, "acct-0").calls()).toEqual( + expect.arrayContaining(["ensureResources()", `commitReconnect(${STAGE_ID})`])); + // The empty request opened no flow: only the two redeemed ones ever existed, both spent. + expect([...user.storage.pendingConnectFlows.list()]).toEqual([]); + }); + }); + it("stages through the gatekeeper-facing callback exactly as a connector calls it", async () => { const { stub, inDo } = freshUser(); const handoff = await inDo(async user => { @@ -286,7 +431,7 @@ describe("connect handoff", () => { }); expect(handoff.targetOrigin).toBe(TARGET); - await stub.completeConnectHandoff(handoff.ticket); + await stub.completeConnectHandoff(handoff.ticket, await stub.openConnectFlow(0)); await inDo(async user => { expect(user.storage.connectedAccounts.get(0)?.vendorId).toBe("github"); // A reconnect finishing on the same callback stages a restore, not a second account. diff --git a/packages/workshop-backend/__tests__/pending-login.test.ts b/packages/workshop-backend/__tests__/pending-login.test.ts index 9fd6b56847..a90219a440 100644 --- a/packages/workshop-backend/__tests__/pending-login.test.ts +++ b/packages/workshop-backend/__tests__/pending-login.test.ts @@ -5,7 +5,9 @@ import { LOGIN_PENDING_LIFETIME_MS, type LoginConnectCallbackImpl, type PendingLogin, } from "../src/auth/login-flow.js"; import type { UserDurableObject } from "../src/user.js"; -import { hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS } from "../src/connect-handoff.js"; +import { + hashPresentedSecret, hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS, +} from "../src/connect-handoff.js"; import type { FakeGatekeeperAccount } from "./test-worker.js"; declare module "cloudflare:workers" { @@ -34,20 +36,53 @@ type UserInternals = UserDurableObject & { let counter = 0; const fresh = () => env.TEST_PENDING_LOGIN.getByName(`pending-login-${++counter}`); -// Claims over the stub the way the browser does, reporting the outcome as a value (a native RPC -// promise left to `.rejects` is also flagged as an unhandled rejection by the pool). -async function claim(stub: DurableObjectStub, ticket: string): Promise { +// The DO as startGatekeeperLogin() names it (the hash newSecretToken() pairs with the nonce) and as +// confirmLogin() finds it again (hashPresentedSecret of the nonce the popup presents): both +// derivations must name the same DO, or every real popup would confirm against one that never began. +async function forNonce() { + const { secret, hash } = await newSecretToken(); + const nonce = secret.toHex(); + expect(await hashPresentedSecret(nonce)).toBe(hash); + return { nonce, stub: env.TEST_PENDING_LOGIN.getByName(hash) }; +} + +// Confirms over the stub the way the popup's page does, reporting the outcome as a value (a native +// RPC promise left to `.rejects` is also flagged as an unhandled rejection by the pool). +async function confirm(stub: DurableObjectStub, ticket: string): Promise { try { - const token = await stub.claim(ticket); + await stub.confirm(ticket); + return "ok"; + } catch (err) { + return `error:${(err as Error).message}`; + } +} + +// Polls over the stub the way the login tab does, reporting the outcome as a value. +async function receive(stub: DurableObjectStub): Promise { + try { + const token = await stub.receive(); return token === null ? "null" : `token:${token}`; } catch (err) { return `error:${(err as Error).message}`; } } +const EXPIRED = "error:This sign-in attempt has expired. Please try again."; + +// Age the stored result without running the alarm; the result is the DO's only entry here. +const age = (stub: DurableObjectStub) => + runInDurableObject(stub, async (instance: PendingLogin) => { + const [[key, stored]] = [...instance.ctx.storage.kv.list()] as [string, { expiresAt: number }][]; + expect(stored.expiresAt).toBeGreaterThan(Date.now()); + instance.ctx.storage.kv.put(key, { ...stored, expiresAt: Date.now() - 1 }); + }); + describe("PendingLogin", () => { - it("releases the token once, and only to the matching ticket", async () => { - const stub = fresh(); + it("releases the token once, and only after the popup confirms the matching ticket", async () => { + const { stub } = await forNonce(); + await stub.begin(); + expect(await receive(stub)).toBe("null"); + const { secret, hash } = await newSecretToken(); await stub.deliver("alice@example.com:session", hash); await runInDurableObject(stub, async (instance: PendingLogin) => { @@ -55,29 +90,37 @@ describe("PendingLogin", () => { expect(await instance.ctx.storage.getAlarm()).toBeLessThanOrEqual( Date.now() + PENDING_HANDOFF_LIFETIME_MS); }); + // Holding the attempt is not enough: the token stays parked until the popup confirms it. + expect(await receive(stub)).toBe("null"); - // Holding the attempt is not enough: the attacker's own tab never sees the ticket. And a ticket - // for some other attempt (the window hears every same-origin broadcast) neither releases the - // token nor spends the result, so the right ticket still can. - const other = fresh(); - await other.deliver("victim@example.com:session", hash); - expect(await claim(other, (await newSecretToken()).secret.toHex())).toBe("null"); - expect(await claim(other, "not-a-ticket")).toBe("null"); - expect(await claim(other, secret.toHex())).toBe("token:victim@example.com:session"); - - expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); - expect(await claim(stub, secret.toHex())) - .toBe("error:This sign-in attempt has expired. Please try again."); + expect(await confirm(stub, secret.toHex())).toBe("ok"); + expect(await receive(stub)).toBe("token:alice@example.com:session"); + expect(await receive(stub)).toBe(EXPIRED); await runInDurableObject(stub, async (instance: PendingLogin) => { expect(await instance.ctx.storage.getAlarm()).toBeNull(); expect([...instance.ctx.storage.kv.list()]).toEqual([]); }); }); - it("answers null to a foreign ticket before the result is delivered", async () => { - // The window hears every same-origin broadcast, so another window's ticket can arrive while the - // user is still at the provider's consent screen. It is not this attempt's, so it must neither - // release anything nor settle the attempt as expired; the attempt keeps waiting. + it("refuses a wrong or malformed ticket without spending the result", async () => { + // A guess must not consume what the right ticket is about to confirm. + const stub = fresh(); + const { secret, hash } = await newSecretToken(); + await stub.deliver("alice@example.com:session", hash); + + expect(await confirm(stub, (await newSecretToken()).secret.toHex())).toBe(EXPIRED); + expect(await confirm(stub, "not-a-ticket")).toBe(EXPIRED); + expect(await confirm(stub, secret.toHex().toUpperCase())).toBe(EXPIRED); + expect(await receive(stub)).toBe("null"); + + expect(await confirm(stub, secret.toHex())).toBe("ok"); + expect(await receive(stub)).toBe("token:alice@example.com:session"); + }); + + it("refuses a ticket before delivery and keeps the attempt pending", async () => { + // A ticket cannot precede the delivery that minted it, so one arriving while the user is still + // at the provider's consent screen is a guess: it must neither settle the attempt as expired nor + // confirm anything; the attempt keeps waiting. const stub = fresh(); await stub.begin(); await runInDurableObject(stub, async (instance: PendingLogin) => { @@ -87,88 +130,111 @@ describe("PendingLogin", () => { expect(await instance.ctx.storage.getAlarm()).toBeLessThanOrEqual( Date.now() + LOGIN_PENDING_LIFETIME_MS); }); - expect(await claim(stub, (await newSecretToken()).secret.toHex())).toBe("null"); - expect(await claim(stub, "not-a-ticket")).toBe("null"); + expect(await confirm(stub, (await newSecretToken()).secret.toHex())).toBe(EXPIRED); + expect(await confirm(stub, "not-a-ticket")).toBe(EXPIRED); + expect(await receive(stub)).toBe("null"); const { secret, hash } = await newSecretToken(); await stub.deliver("alice@example.com:session", hash); - expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); + expect(await confirm(stub, secret.toHex())).toBe("ok"); + expect(await receive(stub)).toBe("token:alice@example.com:session"); }); it("expires an attempt that never delivered", async () => { const stub = fresh(); await stub.begin(); - await runInDurableObject(stub, async (instance: PendingLogin) => { - const [[key, stored]] = [...instance.ctx.storage.kv.list()] as [string, { expiresAt: number }][]; - instance.ctx.storage.kv.put(key, { ...stored, expiresAt: Date.now() - 1 }); - }); + await age(stub); - expect(await claim(stub, (await newSecretToken()).secret.toHex())) - .toBe("error:This sign-in attempt has expired. Please try again."); + expect(await receive(stub)).toBe(EXPIRED); + expect(await confirm(stub, (await newSecretToken()).secret.toHex())).toBe(EXPIRED); }); - it("reports the gatekeeper's failure to whoever claims", async () => { - const stub = fresh(); - await stub.fail("This account has no verified email, so it can't be used to sign in."); + it("reports the gatekeeper's failure to whoever confirms or receives", async () => { + const reason = "This account has no verified email, so it can't be used to sign in."; + const confirming = fresh(); + await confirming.fail(reason); + expect(await confirm(confirming, "f".repeat(64))).toBe(`error:${reason}`); + expect(await receive(confirming)).toBe(EXPIRED); - expect(await claim(stub, "f".repeat(64))) - .toBe("error:This account has no verified email, so it can't be used to sign in."); - expect(await claim(stub, "f".repeat(64))) - .toBe("error:This sign-in attempt has expired. Please try again."); + const receiving = fresh(); + await receiving.fail(reason); + expect(await receive(receiving)).toBe(`error:${reason}`); + expect(await confirm(receiving, "f".repeat(64))).toBe(EXPIRED); }); - it("wipes an unclaimed token from the alarm", async () => { - const stub = fresh(); + it("wipes an unconfirmed and an unreceived token from the alarm", async () => { + const unconfirmed = fresh(); const { secret, hash } = await newSecretToken(); - await stub.deliver("alice@example.com:session", hash); + await unconfirmed.deliver("alice@example.com:session", hash); + await runInDurableObject(unconfirmed, (instance: PendingLogin) => instance.alarm()); + expect(await confirm(unconfirmed, secret.toHex())).toBe(EXPIRED); - await runInDurableObject(stub, (instance: PendingLogin) => instance.alarm()); - expect(await claim(stub, secret.toHex())) - .toBe("error:This sign-in attempt has expired. Please try again."); + const unreceived = fresh(); + await unreceived.deliver("alice@example.com:session", hash); + expect(await confirm(unreceived, secret.toHex())).toBe("ok"); + await runInDurableObject(unreceived, (instance: PendingLogin) => instance.alarm()); + expect(await receive(unreceived)).toBe(EXPIRED); }); - it("refuses a claim past the lifetime even if the alarm has not fired", async () => { - const stub = fresh(); + it("refuses a result past its lifetime even if the alarm has not fired", async () => { + // Validity must not depend on the alarm. Confirming rewrites only the result key, keeping its + // expiry, so the aged entry is the one it finds. const { secret, hash } = await newSecretToken(); - await stub.deliver("alice@example.com:session", hash); - // Age the stored result without running the alarm: validity must not depend on it. - await runInDurableObject(stub, async (instance: PendingLogin) => { - const [[key, stored]] = [...instance.ctx.storage.kv.list()] as [string, { expiresAt: number }][]; - expect(stored.expiresAt).toBeGreaterThan(Date.now()); - instance.ctx.storage.kv.put(key, { ...stored, expiresAt: Date.now() - 1 }); - }); - expect(await claim(stub, secret.toHex())) - .toBe("error:This sign-in attempt has expired. Please try again."); + const unconfirmed = fresh(); + await unconfirmed.deliver("alice@example.com:session", hash); + await age(unconfirmed); + expect(await confirm(unconfirmed, secret.toHex())).toBe(EXPIRED); + + const unreceived = fresh(); + await unreceived.deliver("alice@example.com:session", hash); + expect(await confirm(unreceived, secret.toHex())).toBe("ok"); + await age(unreceived); + expect(await receive(unreceived)).toBe(EXPIRED); }); - it("keeps the account link after the result is claimed or swept", async () => { + it("keeps the account link after the result is confirmed, received or swept", async () => { // The link is what lets the gatekeeper's callback reach the linked account for the rest of its // life, so neither redeeming the sign-in nor the expiry sweep may take it with the result. const stub = fresh(); const { secret, hash } = await newSecretToken(); await stub.link("user-do-id", 3); await stub.deliver("alice@example.com:session", hash); - expect(await claim(stub, secret.toHex())).toBe("token:alice@example.com:session"); + expect(await confirm(stub, secret.toHex())).toBe("ok"); + expect(await stub.getLink()).toEqual({ userId: "user-do-id", accountId: 3 }); + expect(await receive(stub)).toBe("token:alice@example.com:session"); expect(await stub.getLink()).toEqual({ userId: "user-do-id", accountId: 3 }); await stub.deliver("alice@example.com:again", hash); await runInDurableObject(stub, (instance: PendingLogin) => instance.alarm()); - expect(await claim(stub, secret.toHex())) - .toBe("error:This sign-in attempt has expired. Please try again."); + expect(await receive(stub)).toBe(EXPIRED); expect(await stub.getLink()).toEqual({ userId: "user-do-id", accountId: 3 }); }); - it("stores only the ticket's hash", async () => { + it("stores only the ticket's hash, before and after confirmation", async () => { const stub = fresh(); const { secret, hash } = await newSecretToken(); await stub.deliver("alice@example.com:session", hash); - await runInDurableObject(stub, async (instance: PendingLogin) => { + const atRest = () => runInDurableObject(stub, async (instance: PendingLogin) => { const stored = JSON.stringify([...instance.ctx.storage.kv.list()]); expect(stored).not.toContain(secret.toHex()); expect(stored).toContain(await hashSecret(secret)); }); + await atRest(); + expect(await confirm(stub, secret.toHex())).toBe("ok"); + await atRest(); + }); + + it("refuses to confirm an attempt that never began, and stores nothing for it", async () => { + // confirmLogin() addresses the DO by the nonce's hash, so any nonce names some DO: one that no + // startGatekeeperLogin() began holds no result and must not gain one. + const { stub } = await forNonce(); + expect(await confirm(stub, "f".repeat(64))).toBe(EXPIRED); + await runInDurableObject(stub, async (instance: PendingLogin) => { + expect([...instance.ctx.storage.kv.list()]).toEqual([]); + expect(await instance.ctx.storage.getAlarm()).toBeNull(); + }); }); }); diff --git a/packages/workshop-backend/__tests__/test-worker.ts b/packages/workshop-backend/__tests__/test-worker.ts index dd9a35bc0f..a67122a477 100644 --- a/packages/workshop-backend/__tests__/test-worker.ts +++ b/packages/workshop-backend/__tests__/test-worker.ts @@ -48,6 +48,21 @@ export class FakeGatekeeperAccount this.#record(`commitReconnect(${stageId})`); } + async reconnect(): Promise<{ url: string }> { + this.#record("reconnect"); + return { url: `https://gk.example/reconnect/${this.ctx.props.name}` }; + } + + /** + * Nothing to grant for an empty list, as a real gatekeeper answers when the grant already covers + * every requested resource. + */ + async ensureResources(resourceUrlPatterns: string[]): Promise<{ url?: string }> { + this.#record(`ensureResources(${resourceUrlPatterns.join(",")})`); + if (resourceUrlPatterns.length === 0) return {}; + return { url: `https://gk.example/expand/${this.ctx.props.name}` }; + } + async calls(): Promise { return accountCalls.get(this.ctx.props.name) ?? []; } diff --git a/packages/workshop-backend/src/auth/login-flow.ts b/packages/workshop-backend/src/auth/login-flow.ts index e947aaad7c..843b80045a 100644 --- a/packages/workshop-backend/src/auth/login-flow.ts +++ b/packages/workshop-backend/src/auth/login-flow.ts @@ -5,21 +5,24 @@ // mode) with a `LoginConnectCallbackImpl` as the callback and a `PendingLogin` DO to bridge the // result back to the waiting browser: // -// 1. PublicApi.startGatekeeperLogin(vendorId) creates a PendingLogin DO (keyed by a random DO id), -// hands the gatekeeper a LoginConnectCallbackImpl, and returns {url, attempt}, where `attempt` -// is an RpcStub wrapping the DO (so the client awaits via a capability, never a guessable id). -// 2. The browser opens `url` as a popup, keeping itself as the popup's opener. +// 1. PublicApi.startGatekeeperLogin(vendorId) creates a PendingLogin DO (named by the hash of a +// fresh nonce), hands the gatekeeper a LoginConnectCallbackImpl, and returns {url, nonce, +// attempt}, where `attempt` is an RpcStub wrapping the DO (so the client awaits via a +// capability, never a guessable id). +// 2. The browser opens `url` as a disowned popup, after writing the flow's nonce into the popup's +// own sessionStorage. // 3. When the gatekeeper finishes, it calls LoginConnectCallbackImpl.complete(user). We read the // verified email, resolve/create the email-keyed user DO, mint a session, and deliver the token -// to the PendingLogin DO under the hash of a fresh handoff ticket, which complete() returns for -// the gatekeeper's final page to post to its opener (see connect-handoff.ts). -// 4. The opener calls `attempt.claim(ticket)`, and the PendingLogin DO releases the token only for -// a matching ticket; a ticket for some other attempt is answered with null and changes nothing, -// whether it arrives before or after this attempt's result has been delivered. +// to the PendingLogin DO under the hash of a fresh handoff ticket, which complete() returns; the +// gatekeeper's final page navigates the popup to the Workshop's /connect/handoff page with the +// ticket in the URL fragment (see connect-handoff.ts). +// 4. That page calls PublicApi.confirmLogin(ticket, nonce), which finds the DO by the nonce's hash +// and marks the delivered result confirmed. The login tab polls `attempt.receive()`, which +// releases the token once the result is confirmed. // // The sign-in URL is a bearer capability, so step 4 is what binds the session to the browser that -// started the attempt: whoever holds `attempt` but never receives the ticket — an attacker who -// phished a victim into finishing the flow — gets nothing, and the unclaimed token expires. +// started the attempt: whoever holds `attempt` without a popup holding the nonce — an attacker who +// phished a victim into finishing the flow — gets nothing, and the unreceived token expires. // // Sign-in only requests minimal scopes and the gatekeeper grant is transient (it self-destructs // shortly after we read the email) — so login does NOT create a persistent connected account. @@ -37,40 +40,49 @@ import { createWorkshopLogger } from "../observability"; import { CLOUDFLARE_VENDOR_ID, type UserDurableObject } from "../user.js"; import { readAdminConfig } from "../admin-config.js"; import { - handoffTargetOrigin, hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS, + CONNECT_FLOW_LIFETIME_MS, handoffTargetOrigin, hashPresentedSecret, newSecretToken, + PENDING_HANDOFF_LIFETIME_MS, } from "../connect-handoff.js"; const logger = createWorkshopLogger("workshop.auth"); // `pending` is the attempt as started, before the OAuth callback has delivered anything: it lets -// claim() tell "not this attempt's ticket, yet" from an expired or never-started attempt. -type PendingOutcome = { pending: true } | { token: string; ticketHash: string } | { error: string }; -// `expiresAt` bounds the result absolutely: the alarm wipes it too, but claim() must not depend on +// receive() tell "not delivered yet" from an expired or never-started attempt. A delivered token is +// `confirmed` once the popup has presented the matching ticket, and released only then. +type PendingOutcome = + | { pending: true } + | { token: string; ticketHash: string; confirmed: boolean } + | { error: string }; +// `expiresAt` bounds the result absolutely: the alarm wipes it too, but receive() must not depend on // the alarm having fired on time. type PendingResult = PendingOutcome & { expiresAt: number }; /** - * How long a started attempt waits for the gatekeeper to deliver, matching the gatekeepers' own - * connect-nonce lifetime. The shorter PENDING_HANDOFF_LIFETIME_MS is for a delivered result and + * How long a started attempt waits for the gatekeeper to deliver: the one budget every flow that + * ends on the handoff page gets, sign-in and connect alike (CONNECT_FLOW_LIFETIME_MS, sized for the + * gatekeeper's nonces plus the handoff window), so a user the connect flow would still admit is not + * expired by the sign-in flow. The shorter PENDING_HANDOFF_LIFETIME_MS is for a delivered result and * would expire a user who is still at the provider's consent screen. */ -export const LOGIN_PENDING_LIFETIME_MS = 10 * 60 * 1000; +export const LOGIN_PENDING_LIFETIME_MS = CONNECT_FLOW_LIFETIME_MS; // The connected account a sign-in persisted, by the user DO that owns it (see `PendingLogin.link`). type AccountLink = { userId: string; accountId: number }; const RESULT_KEY = "result"; const LINK_KEY = "link"; -const EXPIRED_MESSAGE = "This sign-in attempt has expired. Please try again."; +/** What confirmLogin() and LoginAttempt.receive() throw for an attempt that cannot complete. */ +export const EXPIRED_MESSAGE = "This sign-in attempt has expired. Please try again."; /** * Bridges a login result from the (separate) OAuth-callback invocation back to the browser that * started the attempt. Everything is written to storage, since nothing keeps this DO in memory * between the calls: begin() marks the attempt as started (for LOGIN_PENDING_LIFETIME_MS), so that - * a foreign ticket the browser hears in the meantime is answered with null rather than mistaken for + * receive() answers null while the user is still at the provider rather than mistaking the wait for * an expired attempt; deliver()/fail() replace the marker with the result, which lives for - * PENDING_HANDOFF_LIFETIME_MS at most. An alarm then wipes whatever is left unclaimed. An account - * link (`link`) is kept for as long as the account exists. + * PENDING_HANDOFF_LIFETIME_MS at most; confirm() marks a delivered token as confirmed by the popup + * holding its ticket, and receive() releases it only then. An alarm wipes whatever is left + * unreceived. An account link (`link`) is kept for as long as the account exists. */ export class PendingLogin extends DurableObject { /** Called by PublicApi.startGatekeeperLogin before the gatekeeper flow starts. */ @@ -78,12 +90,15 @@ export class PendingLogin extends DurableObject { await this.#store({ pending: true }, LOGIN_PENDING_LIFETIME_MS); } - /** Called by LoginConnectCallbackImpl on success, with the hash of the ticket that may claim it. */ + /** Called by LoginConnectCallbackImpl on success, with the hash of the ticket that confirms it. */ async deliver(token: string, ticketHash: string): Promise { - await this.#store({ token, ticketHash }); + await this.#store({ token, ticketHash, confirmed: false }); } - /** Called by LoginConnectCallbackImpl when the sign-in cannot complete; claim() reports `reason`. */ + /** + * Called by LoginConnectCallbackImpl when the sign-in cannot complete; confirm() and receive() + * report `reason`. + */ async fail(reason: string): Promise { await this.#store({ error: reason }); } @@ -96,8 +111,8 @@ export class PendingLogin extends DurableObject { /** * Records the connected account this sign-in persisted, so the callback the gatekeeper holds for - * it can reach the account's user DO. Independent of the login result: a sign-in whose ticket is - * never claimed still linked the (owner's own) account. + * it can reach the account's user DO. Independent of the login result: a sign-in whose token is + * never received still linked the (owner's own) account. */ async link(userId: string, accountId: number): Promise { this.ctx.storage.kv.put(LINK_KEY, { userId, accountId }); @@ -108,27 +123,45 @@ export class PendingLogin extends DurableObject { } /** - * Release the token to the holder of the matching ticket. A ticket that is not this attempt's - * (the window may hear every same-origin broadcast) yields null and leaves the result — or the - * still-pending attempt, whose ticket hash is not known yet — in place. Single use otherwise: the - * ticket is hashed before the read, so the read, check and removal of a matching result happen in - * one step under the input gate and a repeat gets no second try. + * Called by PublicApiImpl.confirmLogin from the popup: marks the delivered token as confirmed by + * the holder of the matching ticket, so receive() releases it. A wrong or malformed ticket, or one + * arriving before delivery (a ticket cannot precede the delivery that minted it, so this is a + * guess), throws EXPIRED_MESSAGE without touching the result: it must not consume what the right + * ticket is about to confirm. The ticket is hashed before the read, so the read, check and rewrite + * happen in one step under the input gate. + */ + async confirm(ticket: string): Promise { + const hash = await hashPresentedSecret(ticket); + const result = await this.#result(); + if ("pending" in result || hash !== result.ticketHash) throw new Error(EXPIRED_MESSAGE); + this.ctx.storage.kv.put(RESULT_KEY, { ...result, confirmed: true }); + } + + /** + * Release the token to the holder of the attempt once the popup has confirmed it; null while the + * attempt is still pending or the token is delivered but unconfirmed, so the caller polls. Single + * use: the result is removed with the read, so a repeat gets no second try. */ - async claim(ticket: string): Promise { - const hash = /^[0-9a-f]{64}$/.test(ticket) ? await hashSecret(Uint8Array.fromHex(ticket)) : null; + async receive(): Promise { + const result = await this.#result(); + if ("pending" in result || !result.confirmed) return null; + await this.#clear(); + return result.token; + } + + // The live result, or a throw for an attempt that cannot complete: none (never begun, wiped, or + // already received), expired, or failed; the latter two are cleared as they are reported. + async #result(): Promise> { const result = this.ctx.storage.kv.get(RESULT_KEY); if (!result || Date.now() >= result.expiresAt) { await this.#clear(); throw new Error(EXPIRED_MESSAGE); } - if ("pending" in result) return null; if ("error" in result) { await this.#clear(); throw new Error(result.error); } - if (hash !== result.ticketHash) return null; - await this.#clear(); - return result.token; + return result; } async #clear(): Promise { @@ -153,7 +186,7 @@ export class LoginConnectCallbackImpl /** * Mints the session and parks it in the PendingLogin DO under a fresh ticket's hash; returns the - * handoff whose ticket `LoginAttempt.claim()` must present to receive it. + * handoff whose ticket the popup's page must present to confirmLogin(). */ async complete(account: Fetcher, expiresAt?: Date): Promise { const targetOrigin = handoffTargetOrigin(this.env); diff --git a/packages/workshop-backend/src/connect-handoff.ts b/packages/workshop-backend/src/connect-handoff.ts index 0acb0db7d5..c4c93bc483 100644 --- a/packages/workshop-backend/src/connect-handoff.ts +++ b/packages/workshop-backend/src/connect-handoff.ts @@ -1,9 +1,9 @@ // The connect handoff: how a finished gatekeeper connect flow is bound to the browser that started -// it. A connect URL is a bearer capability, so the gatekeeper's final page delivers a single-use -// ticket to the Workshop — over a same-origin BroadcastChannel for a connect popup (which the -// Workshop disowns before navigating, so the provider never holds its window), or by postMessage to -// its opener for sign-in — and the Workshop activates the staged grant only when that ticket is -// redeemed over the initiating user's own session (UserDurableObject.completeConnectHandoff). +// it. A connect URL is a bearer capability, so the gatekeeper's final page navigates the popup to the +// Workshop's own /connect/handoff page with a single-use ticket in the URL fragment. That page +// redeems the ticket over the popup's own authenticated session +// (UserDurableObject.completeConnectHandoff) together with the nonce the Workshop tab put into the +// popup's sessionStorage when the flow started, and only then is the staged grant activated. /** * How long a staged connect / reconnect waits for its ticket. The handoff page delivers the ticket @@ -13,9 +13,18 @@ export const PENDING_HANDOFF_LIFETIME_MS = 2 * 60 * 1000; /** - * The Workshop origin the handoff page must post its ticket to. Comes from deployment configuration - * only: a request's `Origin` header or anything the client asserts could route the ticket to an - * attacker-controlled opener, so neither is consulted. Fails closed when unset. + * How long a started flow's nonce stays redeemable. A ticket can legitimately arrive up to the + * gatekeepers' initiation-nonce lifetime (10 minutes, e.g. spent on an endpoint form) plus the fresh + * OAuth-nonce lifetime (10 minutes, at the consent screen) plus the handoff lifetime (2 minutes) + * after the flow started; rounded up. + */ +export const CONNECT_FLOW_LIFETIME_MS = 30 * 60 * 1000; + +/** + * The Workshop origin the completion page navigates the popup to with its ticket. Comes from + * deployment configuration only: a request's `Origin` header or anything the client asserts could + * route the ticket to an attacker-controlled origin, so neither is consulted. Fails closed when + * unset. */ export function handoffTargetOrigin(env: Cloudflare.Env): string { if (!env.PUBLIC_BASE_URL) { @@ -26,7 +35,7 @@ export function handoffTargetOrigin(env: Cloudflare.Env): string { /** * Mint a 256-bit bearer secret plus the SHA-256 (hex) under which it is stored, so a leaked storage - * dump reveals nothing redeemable. Shared by session tokens and handoff tickets. + * dump reveals nothing redeemable. Shared by session tokens, handoff tickets and flow nonces. */ export async function newSecretToken(): Promise<{ secret: Uint8Array; hash: string }> { let secret = new Uint8Array(32); @@ -38,3 +47,11 @@ export async function newSecretToken(): Promise<{ secret: Uint8Array; hash: stri export async function hashSecret(secret: Uint8Array): Promise { return new Uint8Array(await crypto.subtle.digest("SHA-256", secret)).toHex(); } + +/** + * The hash under which a secret presented by a client is looked up, or undefined when the value is + * not the 64 lowercase hex characters a ticket or nonce takes (nothing is stored under such a key). + */ +export function hashPresentedSecret(hex: string): Promise | undefined { + return /^[0-9a-f]{64}$/.test(hex) ? hashSecret(Uint8Array.fromHex(hex)) : undefined; +} diff --git a/packages/workshop-backend/src/server.ts b/packages/workshop-backend/src/server.ts index cf05d28a2d..262b951003 100644 --- a/packages/workshop-backend/src/server.ts +++ b/packages/workshop-backend/src/server.ts @@ -1,14 +1,15 @@ import { RpcStub, RpcTarget, newHttpBatchRpcResponse, newWebSocketRpcSession, RpcSessionOptions } from "capnweb"; import { validateRpc } from "capnweb-validate"; import type { JWTPayload } from "jose"; -import { PublicApi, AuthenticatedApi, Overseer, GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, AiGatewayInfo, AiModelProvider, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, ObserverConfigCallback, BlueprintLibrarySummary, BlueprintPublicInfo, BlueprintUserSummary, BlueprintBindingAssignment, AgentSpawnerConfig, WorkpieceId, BLUEPRINT_SCREENSHOT_PATH_PREFIX, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ServerConfig, CloudflareUsageInfo, CloudflareAccountOption, LoginAttempt, GatekeeperAppInfo, AdminApi, GatekeeperVendorInfo, OutputFormatOffer, ListOutputsResult, createOpenGadgetError, getOpenGadgetErrorCode, OPEN_GADGET_ERROR_CODES, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api'; +import { PublicApi, AuthenticatedApi, Overseer, GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, AiGatewayInfo, AiModelProvider, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, ObserverConfigCallback, BlueprintLibrarySummary, BlueprintPublicInfo, BlueprintUserSummary, BlueprintBindingAssignment, AgentSpawnerConfig, WorkpieceId, BLUEPRINT_SCREENSHOT_PATH_PREFIX, BLUEPRINT_SCREENSHOT_R2_PREFIX, blueprintScreenshotUrl, ServerConfig, CloudflareUsageInfo, CloudflareAccountOption, LoginAttempt, GatekeeperAppInfo, AdminApi, GatekeeperVendorInfo, OutputFormatOffer, ListOutputsResult, createOpenGadgetError, getOpenGadgetErrorCode, OPEN_GADGET_ERROR_CODES, AUTH_ERROR_CODES, createAuthError, ConnectFlowStart } from '@gadgets/workshop-shared/api'; import type { UiFeatureFlags } from "@gadgets/workshop-shared/feature-flags"; import { getServerConfig } from "./deployment-config.js"; import { isPasswordAuthEnabled, getAuthGatekeeperAllowlist } from "./auth/config.js"; import { getAuthVendorBinding } from "./auth/auth-vendors.js"; import { getUsageInfo } from "./ai-gateway-billing/limits/usage-checker.js"; import { listConnectedAccounts, selectAccount } from "./ai-gateway-billing/cloudflare/connection-service.js"; -import { PendingLogin, LoginConnectCallbackImpl } from "./auth/login-flow.js"; +import { PendingLogin, LoginConnectCallbackImpl, EXPIRED_MESSAGE } from "./auth/login-flow.js"; +import { hashPresentedSecret, newSecretToken } from "./connect-handoff.js"; import { deploymentOutputForBlueprint, listFormatOffers, readAdminConfig } from "./admin-config.js"; // Re-export the optional-feature Durable Objects + entrypoints so they can be bound in wrangler. @@ -314,15 +315,16 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { return retryOnDoReset(() => this.#user.listGatekeeperVendors(filter)); } - connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise<{url: string}> { + connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise { return this.#user.connectAccount(vendorId, resourceUrlPatterns); } - completeConnectHandoff(ticket: string): Promise { - return this.#user.completeConnectHandoff(ticket); + completeConnectHandoff(ticket: string, nonce: string): Promise { + return this.#user.completeConnectHandoff(ticket, nonce); } - ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}> { + ensureAccountResources(accountId: number, resourceUrlPatterns: string[]) + : Promise { return this.#user.ensureAccountResources(accountId, resourceUrlPatterns); } @@ -344,7 +346,7 @@ class AuthenticatedApiImpl extends RpcTarget implements AuthenticatedApi { return this.#user.disconnectAccount(accountId); } - reconnectAccount(accountId: number): Promise<{url: string}> { + reconnectAccount(accountId: number): Promise { return this.#user.reconnectAccount(accountId); } @@ -623,15 +625,16 @@ async function serveBlueprintScreenshot(env: Env, blueprintId: string): Promise< // Returned by startGatekeeperLogin(). Wraps the PendingLogin DO so the client redeems the login // result through a capability (this stub) rather than a guessable id — no login id is ever exposed -// to the client. The stub alone is not enough: claim() also needs the ticket the popup posts back. +// to the client. The stub alone is not enough: receive() yields the token only once the popup has +// confirmed the attempt's ticket with the nonce (PublicApi.confirmLogin). @validateRpc() class LoginAttemptImpl extends RpcTarget implements LoginAttempt { constructor(private pending: DurableObjectStub) { super(); } - async claim(ticket: string): Promise { - return await this.pending.claim(ticket); + async receive(): Promise { + return await this.pending.receive(); } } @@ -652,7 +655,8 @@ class PublicApiImpl extends RpcTarget implements PublicApi { return getServerConfig(this.env); } - async startGatekeeperLogin(vendorId: string): Promise<{ url: string; attempt: RpcStub }> { + async startGatekeeperLogin(vendorId: string) + : Promise<{ url: string; nonce: string; attempt: RpcStub }> { if (!getAuthGatekeeperAllowlist(this.env).includes(vendorId)) { throw new Error(`Sign-in via "${vendorId}" is not enabled on this deployment.`); } @@ -661,12 +665,15 @@ class PublicApiImpl extends RpcTarget implements PublicApi { const desc = await vendor.describe(); if (!desc.providesAuth) throw new Error(`"${vendorId}" does not provide authentication.`); - // The PendingLogin DO is the rendezvous between this request and the (separate) OAuth-callback - // invocation. The client never sees its id — we hand back an `attempt` stub instead. - const pendingId = this.ctx.exports.PendingLogin.newUniqueId(); + // The PendingLogin DO is the rendezvous between this request, the (separate) OAuth-callback + // invocation, and the popup's confirmLogin(). Its name is the hash of a secret only the popup + // will hold, so confirmLogin can address it while the client side holds no id at all; the client + // redeems through the `attempt` capability. + const { secret, hash } = await newSecretToken(); + const pendingId = this.ctx.exports.PendingLogin.idFromName(hash); const pending = this.ctx.exports.PendingLogin.get(pendingId); - // Mark the attempt as started before the gatekeeper can deliver to it, so a foreign ticket the - // browser hears first is answered with null instead of expiring an attempt that is still running. + // Mark the attempt as started before the gatekeeper can deliver to it, so receive() answers null + // while it is still running instead of reporting it expired. await pending.begin(); const callback = this.ctx.exports.LoginConnectCallbackImpl( { props: { pendingId: pendingId.toString(), vendorId } }); @@ -680,7 +687,16 @@ class PublicApiImpl extends RpcTarget implements PublicApi { const { url } = await vendor.connectAccount(callback, options); // @ts-expect-error Cap'n Web RPC stubs and native RPC targets are compatible but the type // system doesn't know this. - return { url, attempt: new LoginAttemptImpl(pending) }; + return { url, nonce: secret.toHex(), attempt: new LoginAttemptImpl(pending) }; + } + + async confirmLogin(ticket: string, nonce: string): Promise { + // A nonce naming a DO that never began finds no result there and is refused as expired; reading + // an empty DO creates nothing. The ticket is checked by confirm() itself. + const nonceHash = await hashPresentedSecret(nonce); + if (nonceHash === undefined) throw new Error(EXPIRED_MESSAGE); + const id = this.ctx.exports.PendingLogin.idFromName(nonceHash); + await this.ctx.exports.PendingLogin.get(id).confirm(ticket); } async authenticate(token: string): Promise { diff --git a/packages/workshop-backend/src/user.ts b/packages/workshop-backend/src/user.ts index 5c613db9b2..225f76745c 100644 --- a/packages/workshop-backend/src/user.ts +++ b/packages/workshop-backend/src/user.ts @@ -1,5 +1,5 @@ import { RpcStub } from "capnweb"; -import { GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, SUGGESTED_MODELS, CollaboratorRole, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, GadgetMetadata, BlueprintMetadata, BlueprintLibrarySummary, BlueprintSource, BlueprintUserSummary, BLUEPRINT_SCREENSHOT_R2_PREFIX, GatekeeperVendorInfo, BlueprintOutput, OutputSummary, WorkpieceId, ListOutputsResult, AUTH_ERROR_CODES, createAuthError } from '@gadgets/workshop-shared/api'; +import { GadgetMetadataWithTimestamps, AiChatAuthorInfo, AiModelConfig, SUGGESTED_MODELS, CollaboratorRole, ConnectedAccountsSubscriber, ConnectedAccountsFilter, GatekeeperVendorFilter, GadgetMetadata, BlueprintMetadata, BlueprintLibrarySummary, BlueprintSource, BlueprintUserSummary, BLUEPRINT_SCREENSHOT_R2_PREFIX, GatekeeperVendorInfo, BlueprintOutput, OutputSummary, WorkpieceId, ListOutputsResult, AUTH_ERROR_CODES, createAuthError, ConnectFlowStart } from '@gadgets/workshop-shared/api'; import { Gatekeeper, GatekeeperUser, GatekeeperUserVerifier, GatekeeperVendor, AccountDescription, VendorDescription, GatekeeperConnectCallback, ConnectHandoff, SupportedResource, ResourceConfiguratorFrame, AppUiContext, GatekeeperUiFrame } from "@gadgets/workshop-shared/gatekeeper"; import { shouldAutoProvisionAccount, ambientGatekeeperMode } from "./provisioning-policy.js"; import { CloudflareGatekeeperUser } from "@gadgets/workshop-shared/cloudflare-gatekeeper"; @@ -12,7 +12,7 @@ import type { AdminSettings } from "./admin-settings.js"; import { isReservedBlueprintKey, readBlueprintKvRecord } from "./blueprint-archive.js"; import { filterEnabledResources, isResourceDisabled, readAdminConfig } from "./admin-config.js"; import { buildGatekeeperVendorMap } from "./auth/auth-vendors.js"; -import { handoffTargetOrigin, hashSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS } from "./connect-handoff.js"; +import { CONNECT_FLOW_LIFETIME_MS, handoffTargetOrigin, hashPresentedSecret, newSecretToken, PENDING_HANDOFF_LIFETIME_MS } from "./connect-handoff.js"; const logger = createWorkshopLogger("workshop.user"); @@ -49,6 +49,15 @@ type PendingHandoffRecord = { stageId?: string; }; +// A started connect / reconnect / ensure-resources flow, keyed by the hash of the nonce the Workshop +// tab gave the popup (see ConnectFlowStart); completeConnectHandoff requires the ticket's record and +// the nonce's flow to name the same account. Single-use, and swept by alarm() once `expiresAt` passes. +type PendingConnectFlow = { + nonceHash: string; + accountId: number; + expiresAt: Date; +}; + /** * Metadata about an auto-provisioned account that provides an agent singleton and/or a management UI. * Returned to the overseer (ambient capsules / catalog) and the management-UI listing. @@ -189,6 +198,9 @@ function makeUserStorage(storage: DurableObjectStorage) { pendingHandoffs: collection()({ primaryKey: "ticketHash", }), + pendingConnectFlows: collection()({ + primaryKey: "nonceHash", + }), blueprints: collection()({ primaryKey: "id", }), @@ -1155,7 +1167,7 @@ export class UserDurableObject extends DurableObject { return (await Promise.all(promises)).filter(value => value !== null); } - async connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise<{url: string}> { + async connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise { let vendor = this.vendors.get(vendorId); if (!vendor) { throw new Error("No such service: " + vendorId); @@ -1176,10 +1188,11 @@ export class UserDurableObject extends DurableObject { let callback = this.ctx.exports.GatekeeperConnectCallbackImpl({props}); let {url} = await vendor.connectAccount(callback, {resourceUrlPatterns}); + let nonce = await this.openConnectFlow(accountId); logger.info("account connect started", { event: "account.connect.started", vendorId, accountId, }); - return {url}; + return { url, nonce }; } // Iterate every connected-account record, skipping any that fails to load. A record can fail to @@ -1384,10 +1397,13 @@ export class UserDurableObject extends DurableObject { return (record.account as unknown as SingletonAccountStub).startAppUi(context); } - async ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}> { + async ensureAccountResources(accountId: number, resourceUrlPatterns: string[]) + : Promise { let record = this.storage.connectedAccounts.get(accountId); if (!record) throw new Error("No such account."); - return record.account.ensureResources(resourceUrlPatterns); + let { url } = await record.account.ensureResources(resourceUrlPatterns); + if (url === undefined) return null; + return { url, nonce: await this.openConnectFlow(accountId) }; } async subscribeConnectedAccounts( @@ -1554,10 +1570,11 @@ export class UserDurableObject extends DurableObject { } } - async reconnectAccount(accountId: number): Promise<{url: string}> { + async reconnectAccount(accountId: number): Promise { let record = this.storage.connectedAccounts.get(accountId); if (!record) throw new Error("No such account."); - return record.account.reconnect(); + let { url } = await record.account.reconnect(); + return { url, nonce: await this.openConnectFlow(accountId) }; } async startResourceConfigurator( @@ -1694,9 +1711,23 @@ export class UserDurableObject extends DurableObject { // --- Connect handoff (see connect-handoff.ts) --- - // Store a finished-but-unconfirmed flow and hand back the ticket its page must post to the - // Workshop window. Only the ticket's hash is kept, and only in this user's DO, so the ticket is - // redeemable by nobody else (completeConnectHandoff looks it up in the caller's own DO). + /** + * Start a connect / reconnect / ensure-resources flow for `accountId`: mints the nonce the Workshop + * tab gives the popup (see ConnectFlowStart) and keeps its hash, so completeConnectHandoff can + * check that the ticket the flow produces came back through that popup. + */ + async openConnectFlow(accountId: number): Promise { + let { secret, hash: nonceHash } = await newSecretToken(); + let expiresAt = new Date(Date.now() + CONNECT_FLOW_LIFETIME_MS); + this.storage.pendingConnectFlows.put({ nonceHash, accountId, expiresAt }); + await this.#armHandoffSweep(); + return secret.toHex(); + } + + // Store a finished-but-unconfirmed flow and hand back the ticket its page must present, together + // with the flow's nonce, over the initiating user's session. Only the ticket's hash is kept, and + // only in this user's DO, so the ticket is redeemable by nobody else (completeConnectHandoff looks + // it up in the caller's own DO). async #stagePendingHandoff( record: Omit): Promise { let targetOrigin = handoffTargetOrigin(this.env); @@ -1726,19 +1757,30 @@ export class UserDurableObject extends DurableObject { } /** - * Redeem a ticket delivered to this user's browser. The record is deleted before anything else, so - * a ticket is single-use however the rest goes (DO input gates serialize the read and delete); a - * staged connect the redemption cannot activate is dropped like an unredeemed one, so no grant is - * left reachable in a gatekeeper with nothing to revoke it. + * Redeem a finished flow's handoff. Called by the Workshop's own /connect/handoff page in the popup + * over the popup's session; `nonce` proves the popup is the one this user's tab opened for that + * flow. The ticket's record is deleted before anything else, so a ticket is single-use however the + * rest goes (DO input gates serialize the read and delete) and a wrong nonce still spends it; the + * nonce's flow is deleted too, so a nonce cannot be retried against another ticket. A staged + * connect the redemption cannot activate is dropped like an unredeemed one, so no grant is left + * reachable in a gatekeeper with nothing to revoke it. */ - async completeConnectHandoff(ticket: string): Promise { + async completeConnectHandoff(ticket: string, nonce: string): Promise { + let [ticketHash, nonceHash] = + await Promise.all([hashPresentedSecret(ticket), hashPresentedSecret(nonce)]); let record: PendingHandoffRecord | undefined; - if (/^[0-9a-f]{64}$/.test(ticket)) { - let ticketHash = await hashSecret(Uint8Array.fromHex(ticket)); + if (ticketHash !== undefined) { record = this.storage.pendingHandoffs.get(ticketHash); if (record) this.storage.pendingHandoffs.delete(ticketHash); } - if (!record || record.expiresAt.getTime() <= Date.now()) { + let flow: PendingConnectFlow | undefined; + if (nonceHash !== undefined) { + flow = this.storage.pendingConnectFlows.get(nonceHash); + if (flow) this.storage.pendingConnectFlows.delete(nonceHash); + } + let now = Date.now(); + if (!record || record.expiresAt.getTime() <= now || + !flow || flow.expiresAt.getTime() <= now || flow.accountId !== record.accountId) { if (record) await this.#dropPendingConnect(record); throw new Error("This connection attempt has expired. Please try again."); } @@ -1795,11 +1837,10 @@ export class UserDurableObject extends DurableObject { // Arm the alarm for the soonest pending expiry (the alarm is used for nothing else). async #armHandoffSweep(): Promise { let next: number | undefined; + let consider = (at: number) => { if (next === undefined || at < next) next = at; }; + for (let flow of this.storage.pendingConnectFlows.list()) consider(flow.expiresAt.getTime()); try { - for (let pending of this.storage.pendingHandoffs.list()) { - let at = pending.expiresAt.getTime(); - if (next === undefined || at < next) next = at; - } + for (let pending of this.storage.pendingHandoffs.list()) consider(pending.expiresAt.getTime()); } catch (err) { // A record whose stub no longer deserializes (its Worker was unbound) fails the listing, and // without a keys-only listing it cannot be deleted either. Staging a new connect must not @@ -1808,7 +1849,7 @@ export class UserDurableObject extends DurableObject { logger.warn("failed to list pending handoffs", { event: "connect.handoff.arm.failed", error: err, }); - next = Date.now() + PENDING_HANDOFF_LIFETIME_MS; + consider(Date.now() + PENDING_HANDOFF_LIFETIME_MS); } if (next === undefined) { await this.ctx.storage.deleteAlarm(); @@ -1817,9 +1858,15 @@ export class UserDurableObject extends DurableObject { } } - /** Drop pending handoffs whose ticket never came back (see #dropPendingConnect). */ + /** + * Drop pending handoffs whose ticket never came back (see #dropPendingConnect) and flows whose + * nonce was never presented (nothing to revoke for those). + */ async alarm(): Promise { let now = Date.now(); + let expiredFlows = Array.from(this.storage.pendingConnectFlows.list()) + .filter(flow => flow.expiresAt.getTime() <= now); + for (let flow of expiredFlows) this.storage.pendingConnectFlows.delete(flow.nonceHash); let expired: PendingHandoffRecord[] = []; try { for (let pending of this.storage.pendingHandoffs.list()) { diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx index 0748e7b40c..93b9ddccc0 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /* eslint-disable react/react-in-jsx-scope */ -import { act, type ReactElement } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcStub } from 'capnweb' @@ -38,9 +38,6 @@ vi.mock('./useAuth', () => ({ })) import BlueprintLandingPage from './BlueprintLandingPage' -import { AuthProvider } from './AuthContext' -import { CONNECT_HANDOFF_MESSAGE_TYPE } from '@gadgets/workshop-shared/gatekeeper' -import { gatekeeperOrigin } from './connectHandoff' (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const originalInnerWidth = window.innerWidth @@ -136,68 +133,3 @@ describe('BlueprintLandingPage model configuration', () => { expect(save.disabled).toBe(false) }) }) - -// A signed-out visitor who logs in on this page does so through the page's own useAuth(): the root -// route stays standalone, with no AuthProvider and so no app-shell ConnectHandoffListener. The page -// must then redeem connect tickets itself, and must not when the shell is already doing so. -describe('BlueprintLandingPage connect handoff', () => { - let root: Root | undefined - let rootContainer: HTMLDivElement | undefined - const completeConnectHandoff = vi.fn<(ticket: string) => Promise>() - - afterEach(() => { - act(() => root?.unmount()) - rootContainer?.remove() - testState.authenticatedApi = null - completeConnectHandoff.mockReset() - }) - - function apiWithHandoff(): RpcStub { - return { - ...(authenticatedApi() as object), - completeConnectHandoff, - whoami: async () => ({ type: 'user', id: 'alice', name: 'Alice' }), - amIAdmin: async () => false, - } as unknown as RpcStub - } - - async function render(element: ReactElement) { - rootContainer = document.createElement('div') - document.body.appendChild(rootContainer) - root = createRoot(rootContainer) - await act(async () => root!.render(element)) - await act(async () => { await Promise.resolve() }) - } - - async function postTicket() { - window.dispatchEvent(new MessageEvent('message', { - data: { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'c'.repeat(64) }, - origin: gatekeeperOrigin(), - })) - await act(async () => { await Promise.resolve(); await Promise.resolve() }) - } - - it('redeems a connect ticket itself after an inline login', async () => { - completeConnectHandoff.mockResolvedValue(undefined) - testState.authenticatedApi = apiWithHandoff() - await render() - - await postTicket() - - expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith('c'.repeat(64)) - }) - - it('leaves redemption to the app shell when rendered inside it', async () => { - testState.authenticatedApi = apiWithHandoff() - await render( - {}}> - - , - ) - - await postTicket() - - // The shell's own ConnectHandoffListener (not mounted here) is the one that would redeem it. - expect(completeConnectHandoff).not.toHaveBeenCalled() - }) -}) diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.tsx index 5a25a0f55f..82ba8ec260 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.tsx @@ -8,7 +8,6 @@ import { Button, Dialog, DropdownMenu, Select, Tooltip, useKumoToastManager } fr import { ArrowsOutSimple, ArrowLeft, ArrowSquareOut, DotsThree, DownloadSimple, Lightning, Plus, Robot, Sparkle, Star, Trash, X } from '@phosphor-icons/react' import { useAuth } from './useAuth' -import { useOptionalAuthenticatedApi } from './AuthContext' import LoginPage from './LoginPage' import { normalizeResourceUrl } from './resourceMatching' import { @@ -23,7 +22,7 @@ import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER } from './components/menuStyl import { useDocumentTitle } from './useDocumentTitle' import { AccountsSubscriberAdapter } from './accountsSubscriber' import { useDialogSelectPortalContainer } from './useDialogSelectPortalContainer' -import { openConnectWindow, useConnectHandoffListener } from './connectHandoff' +import { openConnectWindow } from './connectHandoff' interface Props { rpcStub: RpcStub @@ -41,16 +40,6 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { const { isAuthenticated, authenticatedApi, isLoading: authLoading, login } = useAuth(rpcStub) const toasts = useKumoToastManager() - // A signed-out visitor who logs in here does so through this page's own useAuth(); the root stays - // in its standalone branch with no AuthProvider, so the app shell's ConnectHandoffListener is not - // mounted and the connect popups below would never complete. Listen here in that case only: when - // the shell is authenticated its listener is already live, and a ticket can be redeemed once. - const shellAuth = useOptionalAuthenticatedApi() - const onHandoffError = useCallback((message: string) => { - toasts.add({ title: 'Could not complete the connection', description: message, variant: 'error' }) - }, [toasts]) - useConnectHandoffListener(shellAuth ? null : authenticatedApi, onHandoffError) - const [blueprint, setBlueprint] = useState(null) useDocumentTitle(blueprint?.metadata.title) const [loading, setLoading] = useState(true) @@ -205,8 +194,7 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { if (!authenticatedApi) return setConnectingVendor(vendorId) try { - const result = await authenticatedApi.connectAccount(vendorId) - openConnectWindow(result.url) + openConnectWindow(await authenticatedApi.connectAccount(vendorId)) toasts.add({ title: 'Complete the account connection in the pop-up window.', variant: 'success' }) } catch (err) { console.error('Failed to initiate connection:', err) @@ -220,8 +208,7 @@ export default function BlueprintLandingPage({ rpcStub }: Props) { if (!authenticatedApi) return setReconnectingAccountId(accountId) try { - const result = await authenticatedApi.reconnectAccount(accountId) - openConnectWindow(result.url) + openConnectWindow(await authenticatedApi.reconnectAccount(accountId)) toasts.add({ title: 'Complete the account reconnect in the pop-up window.', variant: 'success' }) } catch (err) { console.error('Failed to initiate reconnect:', err) diff --git a/packages/workshop-frontend/src/ConnectHandoffListener.tsx b/packages/workshop-frontend/src/ConnectHandoffListener.tsx deleted file mode 100644 index 9f84023f3f..0000000000 --- a/packages/workshop-frontend/src/ConnectHandoffListener.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useCallback } from 'react' -import { useKumoToastManager } from '@cloudflare/kumo' -import { useAuthenticatedApi } from './AuthContext' -import { useConnectHandoffListener } from './connectHandoff' - -/** - * Mounted once inside the authenticated shell (below the toast provider): completes gatekeeper - * connect flows whose popup reports back to this window, surfacing a rejected ticket as a toast. - */ -export function ConnectHandoffListener(): null { - const { authenticatedApi } = useAuthenticatedApi() - const toasts = useKumoToastManager() - const onError = useCallback((message: string) => { - toasts.add({ title: 'Could not complete the connection', description: message, variant: 'error' }) - }, [toasts]) - useConnectHandoffListener(authenticatedApi, onError) - return null -} diff --git a/packages/workshop-frontend/src/ConnectHandoffPage.test.tsx b/packages/workshop-frontend/src/ConnectHandoffPage.test.tsx new file mode 100644 index 0000000000..bce09719e0 --- /dev/null +++ b/packages/workshop-frontend/src/ConnectHandoffPage.test.tsx @@ -0,0 +1,279 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, StrictMode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { AuthenticatedApi, PublicApi } from '@gadgets/workshop-shared/api' + +const testState = vi.hoisted(() => ({ + isLoading: false, + authenticatedApi: null as RpcStub | null, +})) + +vi.mock('./useAuth', () => ({ + useAuth: () => ({ + isAuthenticated: testState.authenticatedApi !== null, + authenticatedApi: testState.authenticatedApi, + isLoading: testState.isLoading, + login: vi.fn<(token: string) => void>(), + logout: vi.fn<() => void>(), + }), +})) + +import ConnectHandoffPage from './ConnectHandoffPage' +import { RpcContext } from './RpcContext' +import { HANDOFF_KEY, HANDOFF_PATH } from './connectHandoff' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const TICKET = 'a'.repeat(64) +const NONCE = 'b'.repeat(64) + +// Lets the RPC promise settle and React flush. +const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve() }) + +describe('ConnectHandoffPage', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + const completeConnectHandoff = vi.fn<(ticket: string, nonce: string) => Promise>() + const confirmLogin = vi.fn<(ticket: string, nonce: string) => Promise>() + const stub = { confirmLogin } as unknown as RpcStub + const close = vi.fn<() => void>() + // What the RpcContext provider hands the page; a reconnect replaces `stub` with a new object. + let provider: { stub: RpcStub; connectionLost: boolean } + + beforeEach(() => { + vi.spyOn(window, 'close').mockImplementation(close) + }) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + vi.restoreAllMocks() + completeConnectHandoff.mockReset() + confirmLogin.mockReset() + close.mockReset() + testState.isLoading = false + testState.authenticatedApi = null + provider = { stub, connectionLost: false } + sessionStorage.clear() + window.history.replaceState(null, '', '/') + }) + + // The popup's state as the gatekeeper's final page leaves it: the ticket in the fragment, and the + // record the Workshop tab wrote before navigating it. + function arrive(hash: string | null, record: unknown) { + window.history.replaceState(null, '', hash === null ? HANDOFF_PATH : `${HANDOFF_PATH}#${hash}`) + if (record !== undefined) { + sessionStorage.setItem(HANDOFF_KEY, typeof record === 'string' ? record : JSON.stringify(record)) + } + } + + // `strict` mounts under StrictMode as main.tsx does, which double-invokes the state initializer + // that spends the storage record and replays the effects. + let strictMode = false + function tree() { + const page = ( + + + + ) + return strictMode ? {page} : page + } + + async function render({ strict = false } = {}) { + strictMode = strict + provider = { stub, connectionLost: false } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { root!.render(tree()) }) + await settle() + return container + } + + const rerender = () => act(async () => { root!.render(tree()) }) + + const signedIn = () => { + testState.authenticatedApi = { completeConnectHandoff } as unknown as RpcStub + } + + it('redeems a connect ticket with the nonce once, strips the fragment, spends the record, and closes', async () => { + completeConnectHandoff.mockResolvedValue(undefined) + signedIn() + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render({ strict: true }) + await rerender() + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET, NONCE) + expect(window.location.hash).toBe('') + expect(window.location.pathname).toBe(HANDOFF_PATH) + expect(sessionStorage.getItem(HANDOFF_KEY)).toBeNull() + expect(close).toHaveBeenCalledOnce() + expect(page.textContent).toContain('Connected') + expect(page.textContent).toContain('You can close this window.') + expect(confirmLogin).not.toHaveBeenCalled() + }) + + it('shows the server message when a connect ticket is rejected', async () => { + completeConnectHandoff.mockRejectedValue(new Error('This connection attempt has expired.')) + signedIn() + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render() + + expect(page.textContent).toContain('Could not complete the connection') + expect(page.textContent).toContain('This connection attempt has expired.') + expect(close).not.toHaveBeenCalled() + }) + + it('reports the link invalid and calls nothing without a storage record', async () => { + signedIn() + arrive(TICKET, undefined) + + const page = await render() + + expect(page.textContent).toContain("This link isn't valid") + expect(page.textContent).toContain('Reload the Workshop and start the connection again.') + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(confirmLogin).not.toHaveBeenCalled() + expect(window.location.hash).toBe('') + }) + + it('reports the link invalid for a malformed fragment', async () => { + signedIn() + arrive('not-a-ticket', { kind: 'connect', nonce: NONCE }) + + const page = await render() + + expect(page.textContent).toContain("This link isn't valid") + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(sessionStorage.getItem(HANDOFF_KEY)).toBeNull() + }) + + it('confirms a sign-in ticket over the public API and closes', async () => { + confirmLogin.mockResolvedValue(undefined) + arrive(TICKET, { kind: 'login', nonce: NONCE }) + + const page = await render() + + expect(confirmLogin).toHaveBeenCalledExactlyOnceWith(TICKET, NONCE) + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledOnce() + expect(page.textContent).toContain('Signed in') + expect(page.textContent).toContain('You can close this window.') + }) + + it('shows the server message when a sign-in ticket is rejected', async () => { + confirmLogin.mockRejectedValue(new Error('This sign-in attempt has expired.')) + arrive(TICKET, { kind: 'login', nonce: NONCE }) + + const page = await render() + + expect(page.textContent).toContain('Could not sign in') + expect(page.textContent).toContain('This sign-in attempt has expired.') + expect(close).not.toHaveBeenCalled() + }) + + it('tells a signed-out connect popup to sign in first, calling nothing', async () => { + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render() + + expect(page.textContent).toContain("You're signed out") + expect(page.textContent).toContain('Sign in to the Workshop and start the connection again.') + expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(confirmLogin).not.toHaveBeenCalled() + }) + + it('waits for auth before redeeming a connect ticket', async () => { + completeConnectHandoff.mockResolvedValue(undefined) + testState.isLoading = true + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render() + expect(page.textContent).toContain('Finishing up…') + expect(completeConnectHandoff).not.toHaveBeenCalled() + + testState.isLoading = false + signedIn() + await rerender() + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET, NONCE) + expect(page.textContent).toContain('Connected') + }) + + it('retries over the reconnected session when the first attempt died with the socket', async () => { + // capnweb rejects every call pending on a socket that closes, and main.tsx then publishes a + // stub for the replacement connection on which useAuth re-authenticates. The redemption is + // presented again over that session; ticket and nonce are single-use server-side, so a repeat + // of a call that did land is refused as expired and changes nothing. + completeConnectHandoff + .mockRejectedValueOnce(new Error('RPC session was broken')) + .mockResolvedValueOnce(undefined) + signedIn() + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render() + expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET, NONCE) + expect(page.textContent).toContain('Could not complete the connection') + + provider = { stub, connectionLost: true } + await rerender() + expect(page.textContent).toContain('Finishing up…') + expect(page.textContent).not.toContain('Could not complete the connection') + expect(completeConnectHandoff).toHaveBeenCalledOnce() + + provider = { stub: { confirmLogin } as unknown as RpcStub, connectionLost: false } + signedIn() + await rerender() + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledTimes(2) + expect(completeConnectHandoff).toHaveBeenLastCalledWith(TICKET, NONCE) + expect(page.textContent).toContain('Connected') + expect(close).toHaveBeenCalledOnce() + }) + + it('does not retry on a re-render with the same session', async () => { + completeConnectHandoff.mockRejectedValue(new Error('This connection attempt has expired.')) + signedIn() + arrive(TICKET, { kind: 'connect', nonce: NONCE }) + + const page = await render({ strict: true }) + await rerender() + await settle() + + expect(completeConnectHandoff).toHaveBeenCalledOnce() + expect(page.textContent).toContain('This connection attempt has expired.') + }) + + it('retries a sign-in confirmation over the reconnected session', async () => { + confirmLogin + .mockRejectedValueOnce(new Error('RPC session was broken')) + .mockResolvedValueOnce(undefined) + arrive(TICKET, { kind: 'login', nonce: NONCE }) + + const page = await render() + expect(confirmLogin).toHaveBeenCalledExactlyOnceWith(TICKET, NONCE) + expect(page.textContent).toContain('Could not sign in') + + // The same session again: nothing is re-sent. + await rerender() + await settle() + expect(confirmLogin).toHaveBeenCalledOnce() + + provider = { stub: { confirmLogin } as unknown as RpcStub, connectionLost: false } + await rerender() + await settle() + + expect(confirmLogin).toHaveBeenCalledTimes(2) + expect(confirmLogin).toHaveBeenLastCalledWith(TICKET, NONCE) + expect(page.textContent).toContain('Signed in') + }) +}) diff --git a/packages/workshop-frontend/src/ConnectHandoffPage.tsx b/packages/workshop-frontend/src/ConnectHandoffPage.tsx new file mode 100644 index 0000000000..d62175f3dd --- /dev/null +++ b/packages/workshop-frontend/src/ConnectHandoffPage.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from 'react' +import type { RpcStub } from 'capnweb' +import type { AuthenticatedApi, PublicApi } from '@gadgets/workshop-shared/api' +import { useConnectionLost, useRpcStub } from './RpcContext' +import { useAuth } from './useAuth' +import { readPopupHandoff, ticketFromHandoffFragment } from './connectHandoff' + +/** + * What the page tells the user once the outcome is known: a heading and one line of detail. + * `failed` marks a redemption the server or the transport rejected, as opposed to one that never + * reached the server (INVALID, SIGNED_OUT) or succeeded. + */ +type Outcome = { title: string; detail: string; failed?: true } + +const INVALID: Outcome = { + title: "This link isn't valid", + detail: 'Reload the Workshop and start the connection again.', +} +const SIGNED_OUT: Outcome = { + title: "You're signed out", + detail: 'Sign in to the Workshop and start the connection again.', +} +const CLOSE_HINT = 'You can close this window.' + +/** + * The page a finished connect / sign-in popup lands on (HANDOFF_PATH), with the single-use ticket + * in the URL fragment. It redeems the ticket together with the flow's nonce, which only this popup + * holds: sessionStorage is per top-level browsing context and per origin, so what the Workshop tab + * wrote into this popup before navigating it (`openDisownedPopup`) is readable again here, after + * the trip through the gatekeeper and the provider, and nowhere else. A handoff link opened any + * other way carries no nonce and is reported invalid without a call to the server. + * + * A connect is redeemed over this popup's own authenticated session (`completeConnectHandoff`), + * established like any Workshop tab's (the shared 'authToken', or the Cloudflare Access identity + * in an Access deployment); the account then reaches the tab that started the flow through + * `subscribeConnectedAccounts()`. A sign-in popup has no session: + * it confirms the ticket over the public API (`confirmLogin`), and the login tab collects the token + * from its own `LoginAttempt`. The page therefore runs standalone, outside the app shell and + * without waiting on the root's auth, with its own `useAuth` for the connect case. + * + * The fragment is stripped once read, and the storage record is spent as it is read, so neither a + * reload nor a re-render can present the ticket twice. The one repeat is deliberate: a redemption + * that failed is presented again over the next session, when the RPC connection has been replaced + * after an outage (`main.tsx` swaps the stub once per reconnect, and `useAuth` re-authenticates on + * it). That is safe because ticket and nonce are single-use server-side: if the first call did + * reach the server, the repeat is refused as expired and changes nothing; if it died with the + * socket, the repeat is the first the server hears of it. This page is the only surface a rejected + * ticket is reported on, so the server's message is shown verbatim. + */ +export default function ConnectHandoffPage() { + const rpcStub = useRpcStub() + const connectionLost = useConnectionLost() + const { authenticatedApi, isLoading } = useAuth(rpcStub) + // Read once: the storage record is consumed by reading it, and the fragment is stripped below. + const [{ ticket, handoff }] = useState(() => ({ + ticket: ticketFromHandoffFragment(window.location.hash), + handoff: readPopupHandoff(), + })) + const [result, setResult] = useState(null) + // The session the ticket was last presented over. It is presented at most once per session, + // whatever re-renders or StrictMode replays, and again over a new session only if the previous + // presentation failed. + const sentWithRef = useRef | RpcStub | null>(null) + + // In an effect rather than during render: the router patches replaceState. + useEffect(() => { + window.history.replaceState(window.history.state, '', window.location.pathname) + }, []) + + useEffect(() => { + if (ticket === null || handoff === null) return + let api: RpcStub | RpcStub + let redeem: () => Promise + let done: Outcome + let failed: string + if (handoff.kind === 'connect') { + if (isLoading || authenticatedApi === null) return + api = authenticatedApi + redeem = () => authenticatedApi.completeConnectHandoff(ticket, handoff.nonce) + done = { title: 'Connected', detail: CLOSE_HINT } + failed = 'Could not complete the connection' + } else { + api = rpcStub + redeem = () => rpcStub.confirmLogin(ticket, handoff.nonce) + done = { title: 'Signed in', detail: CLOSE_HINT } + failed = 'Could not sign in' + } + if (sentWithRef.current === api) return + if (sentWithRef.current !== null && !result?.failed) return + sentWithRef.current = api + setResult(null) + redeem().then( + () => { + // Browsers may refuse to close a window a script did not open; the hint covers that. + window.close() + setResult(done) + }, + (err: unknown) => { + setResult({ + title: failed, + detail: err instanceof Error ? err.message : String(err), + failed: true, + }) + }, + ) + }, [ticket, handoff, isLoading, authenticatedApi, rpcStub, result]) + + let outcome = result + if (ticket === null || handoff === null) outcome = INVALID + // A failure while the connection is down is the socket's, not the server's: the redemption is + // presented again once the session is back, so show the wait rather than a transient error. + else if (outcome?.failed && connectionLost) outcome = null + else if (outcome === null && handoff.kind === 'connect' && !isLoading && authenticatedApi === null) { + outcome = SIGNED_OUT + } + + return ( +
+

{outcome?.title ?? 'Finishing up…'}

+ {outcome &&

{outcome.detail}

} +
+ ) +} diff --git a/packages/workshop-frontend/src/GatekeeperModal.tsx b/packages/workshop-frontend/src/GatekeeperModal.tsx index 7e53793141..958ed7a534 100644 --- a/packages/workshop-frontend/src/GatekeeperModal.tsx +++ b/packages/workshop-frontend/src/GatekeeperModal.tsx @@ -587,8 +587,7 @@ export default function GatekeeperModal({ const handleConnectAccount = async (vendorId: string, resourceUrlPatterns?: string[]) => { setConnectingVendor(vendorId) try { - const result = await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns) - openConnectWindow(result.url) + openConnectWindow(await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns)) toasts.add({ title: 'Complete the account connection in the pop-up window.', variant: 'success' }) } catch (error) { console.error('Failed to initiate connection:', error) @@ -608,13 +607,13 @@ export default function GatekeeperModal({ if (missing.length === 0) return setGrantingAccountId(accountId) try { - const result = await authenticatedApi.ensureAccountResources(accountId, missing) - if (result.url) { - openConnectWindow(result.url) + const flow = await authenticatedApi.ensureAccountResources(accountId, missing) + if (flow) { + openConnectWindow(flow) toasts.add({ title: 'Grant the additional access in the pop-up window.', variant: 'success' }) } - // The new grant arrives via subscribeConnectedAccounts(); the account's flag then clears and - // the configurator loads automatically. + // The popup redeems the ticket itself; the new grant arrives via subscribeConnectedAccounts(), + // the account's flag then clears and the configurator loads automatically. } catch (error) { console.error('Failed to request additional access:', error) reportIssue('gatekeeper.resource-grant', error, { @@ -629,8 +628,7 @@ export default function GatekeeperModal({ const handleReconnectAccount = async (accountId: number) => { setReconnectingAccountId(accountId) try { - const result = await authenticatedApi.reconnectAccount(accountId) - openConnectWindow(result.url) + openConnectWindow(await authenticatedApi.reconnectAccount(accountId)) toasts.add({ title: 'Complete the account reconnect in the pop-up window.', variant: 'success' }) } catch (error) { console.error('Failed to initiate reconnect:', error) diff --git a/packages/workshop-frontend/src/ObserverConfigModal.test.tsx b/packages/workshop-frontend/src/ObserverConfigModal.test.tsx index 1cbad2bfae..fdc5c7815f 100644 --- a/packages/workshop-frontend/src/ObserverConfigModal.test.tsx +++ b/packages/workshop-frontend/src/ObserverConfigModal.test.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import type { RpcStub } from 'capnweb' import type { AuthenticatedApi, + ConnectFlowStart, ConnectedAccountsSubscriber, ObserverAccountChoice, ObserverBindingNeed, @@ -82,12 +83,12 @@ type ApiOverrides = { subscribeConnectedAccounts?: Mock<( subscriber: ConnectedAccountsSubscriber, ) => Promise<{ [Symbol.dispose](): void }>> - connectAccount?: Mock<(vendorId: string, resourceUrlPatterns?: string[]) => Promise<{ url: string }>> + connectAccount?: Mock<(vendorId: string, resourceUrlPatterns?: string[]) => Promise> ensureAccountResources?: Mock<( accountId: number, resourceUrlPatterns: string[], - ) => Promise<{ url?: string }>> - reconnectAccount?: Mock<(accountId: number) => Promise<{ url: string }>> + ) => Promise> + reconnectAccount?: Mock<(accountId: number) => Promise> } function fakeApi( @@ -111,17 +112,25 @@ function fakeApi( }], listAddableGatekeepers: async () => [], connectAccount: overrides.connectAccount ?? - vi.fn<(vendorId: string, resourceUrlPatterns?: string[]) => Promise<{ url: string }>>(), + vi.fn<(vendorId: string, resourceUrlPatterns?: string[]) => Promise>(), ensureAccountResources: overrides.ensureAccountResources ?? - vi.fn<(accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }>>(), + vi.fn<(accountId: number, resourceUrlPatterns: string[]) => Promise>(), reconnectAccount: overrides.reconnectAccount ?? - vi.fn<(accountId: number) => Promise<{ url: string }>>(), + vi.fn<(accountId: number) => Promise>(), } as unknown as RpcStub } -// The popup openConnectWindow gets back: opened blank, then navigated to the connect URL. +// The flow a connect / ensure-resources fake starts: the URL to open and the nonce the popup carries. +const FLOW: ConnectFlowStart = { url: 'https://accounts.google.test/oauth', nonce: 'a'.repeat(64) } + +// The popup openConnectWindow gets back: opened blank, given the nonce, then navigated to the URL. function mockConnectPopup() { - const popup = { close() {}, opener: window as Window | null, location: { replace: vi.fn<(url: string) => void>() } } + const popup = { + close() {}, + opener: window as Window | null, + sessionStorage: { setItem: vi.fn<(key: string, value: string) => void>() }, + location: { replace: vi.fn<(url: string) => void>() }, + } vi.spyOn(window, 'open').mockImplementation(() => popup as unknown as Window) return popup } @@ -196,8 +205,8 @@ describe('ObserverConfigModal account selection', () => { it('requests the resource scope when connecting a new account', async () => { const connectAccount = vi.fn< - (vendorId: string, resourceUrlPatterns?: string[]) => Promise<{ url: string }> - >().mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) + (vendorId: string, resourceUrlPatterns?: string[]) => Promise + >().mockResolvedValue(FLOW) const popup = mockConnectPopup() const rendered = await render([], { api: fakeApi([], { connectAccount }), @@ -209,15 +218,14 @@ describe('ObserverConfigModal account selection', () => { await act(async () => connect!.click()) expect(connectAccount).toHaveBeenCalledWith('google', [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(window.open).toHaveBeenCalledWith('', expect.stringMatching(/^gadgets-connect-/), 'popup,width=520,height=680') expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') }) it('expands an existing account grant before allowing verification', async () => { const ensureAccountResources = vi.fn< - (accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }> - >() - .mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) + (accountId: number, resourceUrlPatterns: string[]) => Promise + >().mockResolvedValue(FLOW) const popup = mockConnectPopup() const underScoped = account(1, 'dan@cloudflare.com', [GMAIL_RESOURCE_PATTERN]) const rendered = await render([underScoped], { @@ -235,7 +243,7 @@ describe('ObserverConfigModal account selection', () => { await act(async () => grant!.click()) expect(ensureAccountResources).toHaveBeenCalledWith(1, [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(window.open).toHaveBeenCalledWith('', expect.stringMatching(/^gadgets-connect-/), 'popup,width=520,height=680') expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') expect(rendered.textContent).not.toContain('Ready') expect(verify?.disabled).toBe(true) @@ -243,8 +251,8 @@ describe('ObserverConfigModal account selection', () => { it('checks the resource grant when legacy account metadata omits it', async () => { const ensureAccountResources = vi.fn< - (accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }> - >().mockResolvedValue({ url: 'https://accounts.google.test/oauth' }) + (accountId: number, resourceUrlPatterns: string[]) => Promise + >().mockResolvedValue(FLOW) const popup = mockConnectPopup() const legacy = account(1, 'dan@cloudflare.com') const rendered = await render([legacy], { @@ -261,14 +269,14 @@ describe('ObserverConfigModal account selection', () => { await act(async () => grant!.click()) expect(ensureAccountResources).toHaveBeenCalledWith(1, [DOC_RESOURCE.urlPattern]) - expect(window.open).toHaveBeenCalledWith('', 'gadgets-connect', 'popup,width=520,height=680') + expect(window.open).toHaveBeenCalledWith('', expect.stringMatching(/^gadgets-connect-/), 'popup,width=520,height=680') expect(popup.location.replace).toHaveBeenCalledWith('https://accounts.google.test/oauth') }) it('allows verification when the gatekeeper confirms an unknown grant needs no OAuth', async () => { const ensureAccountResources = vi.fn< - (accountId: number, resourceUrlPatterns: string[]) => Promise<{ url?: string }> - >().mockResolvedValue({}) + (accountId: number, resourceUrlPatterns: string[]) => Promise + >().mockResolvedValue(null) const legacy = account(1, 'dan@cloudflare.com') const rendered = await render([legacy], { api: fakeApi([legacy], { ensureAccountResources }), diff --git a/packages/workshop-frontend/src/ObserverConfigModal.tsx b/packages/workshop-frontend/src/ObserverConfigModal.tsx index 0e73422272..7cfc3b7d72 100644 --- a/packages/workshop-frontend/src/ObserverConfigModal.tsx +++ b/packages/workshop-frontend/src/ObserverConfigModal.tsx @@ -212,11 +212,10 @@ export default function ObserverConfigModal({ await authenticatedApi.provisionAmbientAccount(vendorId) } else { const required = requiredResourceUrlPatterns(need, vendor) - const { url } = await authenticatedApi.connectAccount( + openConnectWindow(await authenticatedApi.connectAccount( vendorId, required.length > 0 ? required : undefined, - ) - openConnectWindow(url) + )) } } catch (err) { console.error('Failed to initiate connection:', err) @@ -229,9 +228,9 @@ export default function ObserverConfigModal({ const handleReconnect = async (accountId: number) => { setReconnecting(accountId) try { - const { url } = await authenticatedApi.reconnectAccount(accountId) - openConnectWindow(url) - // Subscription fires add() with credentialsValid:true on completion, clearing `reconnecting`. + openConnectWindow(await authenticatedApi.reconnectAccount(accountId)) + // The popup redeems the ticket itself; the account arrives through the accounts subscription, + // whose add() with credentialsValid:true clears `reconnecting`. } catch (err) { console.error('Failed to initiate reconnection:', err) toasts.add({ title: 'Failed to start re-authentication flow', variant: 'error' }) @@ -249,8 +248,8 @@ export default function ObserverConfigModal({ if (missing.length === 0) return setGranting(account.id) try { - const { url } = await authenticatedApi.ensureAccountResources(account.id, missing) - if (url) openConnectWindow(url) + const flow = await authenticatedApi.ensureAccountResources(account.id, missing) + if (flow) openConnectWindow(flow) else { // The gatekeeper confirmed this account already has access. Update the modal so the user can // continue without an OAuth flow. diff --git a/packages/workshop-frontend/src/OnboardingWizard.tsx b/packages/workshop-frontend/src/OnboardingWizard.tsx index 90885796ba..d0569f8c6a 100644 --- a/packages/workshop-frontend/src/OnboardingWizard.tsx +++ b/packages/workshop-frontend/src/OnboardingWizard.tsx @@ -252,8 +252,7 @@ export default function OnboardingWizard({ const handleConnect = async (vendorId: string) => { setConnectingVendorId(vendorId) try { - const { url } = await authenticatedApi.connectAccount(vendorId) - openConnectWindow(url) + openConnectWindow(await authenticatedApi.connectAccount(vendorId)) } catch (err) { console.error('Failed to start connection:', err) toasts.add({ title: 'Failed to start connection', variant: 'error' }) diff --git a/packages/workshop-frontend/src/ResourcePicker.tsx b/packages/workshop-frontend/src/ResourcePicker.tsx index 79169e9ddd..9b4f071458 100644 --- a/packages/workshop-frontend/src/ResourcePicker.tsx +++ b/packages/workshop-frontend/src/ResourcePicker.tsx @@ -399,8 +399,7 @@ export default function ResourcePicker({ const handleConnectNew = async (vendorId: string, resourceUrlPatterns?: string[]) => { setConnectingVendor(vendorId) try { - const result = await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns) - openConnectWindow(result.url) + openConnectWindow(await authenticatedApi.connectAccount(vendorId, resourceUrlPatterns)) } catch (error) { console.error('Failed to initiate connection:', error) toasts.add({ title: 'Failed to start connection flow', variant: 'error' }) @@ -415,9 +414,9 @@ export default function ResourcePicker({ if (resourceUrlPatterns.length === 0) return setGrantingAccount(accountId) try { - const result = await authenticatedApi.ensureAccountResources(accountId, resourceUrlPatterns) - if (result.url) { - openConnectWindow(result.url) + const flow = await authenticatedApi.ensureAccountResources(accountId, resourceUrlPatterns) + if (flow) { + openConnectWindow(flow) toasts.add({ title: 'Grant the additional access in the pop-up window.', variant: 'success' }) } } catch (error) { @@ -433,10 +432,9 @@ export default function ResourcePicker({ const handleReconnect = useCallback(async (accountId: number) => { setReconnectingAccount(accountId) try { - const result = await authenticatedApi.reconnectAccount(accountId) - openConnectWindow(result.url) - // The subscription will fire add() with credentialsValid: true when reconnect completes. - // The reconnectingAccount state is cleared at that point. + openConnectWindow(await authenticatedApi.reconnectAccount(accountId)) + // The popup redeems the ticket itself; the account arrives through the accounts subscription, + // whose add() with credentialsValid: true clears the reconnectingAccount state. } catch (error) { console.error('Failed to initiate reconnection:', error) toasts.add({ title: 'Failed to start re-authentication flow', variant: 'error' }) diff --git a/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx b/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx index 06bb0516cc..c86c367066 100644 --- a/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx +++ b/packages/workshop-frontend/src/components/auth/OAuthButtons.test.tsx @@ -3,19 +3,19 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcStub } from 'capnweb' import type { AuthVendorInfo, LoginAttempt, PublicApi } from '@gadgets/workshop-shared/api' -import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, -} from '@gadgets/workshop-shared/gatekeeper' -import { gatekeeperOrigin } from '../../connectHandoff' +import { HANDOFF_KEY } from '../../connectHandoff' import OAuthButtons from './OAuthButtons' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const VENDORS: AuthVendorInfo[] = [{ vendorId: 'github', displayName: 'GitHub' }] -const TICKET = 'b'.repeat(64) +const NONCE = 'b'.repeat(64) +const URL = 'https://gk.example/login' +const FEATURES = 'popup,width=520,height=680' +const TOKEN = 'alice@example.com:secret' function deferred() { let resolve!: (value: T) => void @@ -25,163 +25,312 @@ function deferred() { // Lets pending promises and React flush. const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve() }) +// One receive() poll tick. +const tick = () => act(() => vi.advanceTimersByTimeAsync(1000)) + +// A popup as window.open returns it: opened blank with its own storage, disowned, then navigated. +function fakePopup() { + const store = new Map() + const popup = { + closed: false, + close: vi.fn<() => void>(), + opener: window as Window | null, + openerAtReplace: undefined as Window | null | undefined, + sessionStorage: { + store, + setItem: vi.fn<(key: string, value: string) => void>((key, value) => { store.set(key, value) }), + }, + location: { + replace: vi.fn<(url: string) => void>(() => { popup.openerAtReplace = popup.opener }), + }, + } + return popup +} describe('OAuthButtons', () => { let root: Root | undefined let container: HTMLDivElement | undefined - const claim = vi.fn<(ticket: string) => Promise>() - const attempt = { claim, [Symbol.dispose]() {} } as unknown as RpcStub - const popup = { closed: false, close: vi.fn<() => void>() } as unknown as Window + const receive = vi.fn<() => Promise>() + const attempt = { receive, [Symbol.dispose]() {} } as unknown as RpcStub + const rpcStub = { + startGatekeeperLogin: async () => ({ url: URL, nonce: NONCE, attempt }), + } as unknown as RpcStub - function mount(rpcStub: RpcStub, onSuccess = vi.fn<() => void>()) { + function mount(stub: RpcStub = rpcStub, onSuccess = vi.fn<() => void>()) { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root!.render()) + act(() => root!.render()) return onSuccess } - const clickSignIn = () => act(async () => { container!.querySelector('button')!.click() }) + const button = () => container!.querySelector('button')! + const clickSignIn = () => act(async () => { button().click() }) - const deliver = (source: Window | null, ticket = TICKET) => window.dispatchEvent( - new MessageEvent('message', { - data: { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket }, origin: gatekeeperOrigin(), source, - })) + beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { act(() => root?.unmount()) container?.remove() vi.restoreAllMocks() vi.useRealTimers() - claim.mockReset() + receive.mockReset() localStorage.clear() }) - it('claims only the ticket its own popup posts', async () => { - vi.spyOn(window, 'open').mockReturnValue(popup) - claim.mockResolvedValue('alice@example.com:secret') - const rpcStub = { - startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), - } as unknown as RpcStub - const onSuccess = mount(rpcStub) + it('opens a fresh disowned popup carrying the nonce, then navigates it', async () => { + const popup = fakePopup() + const open = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + receive.mockResolvedValue(null) + mount() await clickSignIn() await settle() - expect(window.open).toHaveBeenCalledWith( - 'https://gk.example/login', 'gatekeeper-login', 'popup,width=520,height=680') - // A ticket from some other window (an account-connect popup, say) is not this attempt's. - deliver({ close() {} } as unknown as Window) + expect(open).toHaveBeenCalledExactlyOnceWith('', expect.stringMatching(/^gatekeeper-login-/), FEATURES) + expect(open.mock.calls[0][2]).not.toContain('noopener') + expect(popup.openerAtReplace).toBeNull() + expect(popup.sessionStorage.setItem).toHaveBeenCalledExactlyOnceWith( + HANDOFF_KEY, JSON.stringify({ kind: 'login', nonce: NONCE })) + expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith(URL) + // The nonce is written while the popup is still our about:blank, before the navigation. + expect(popup.sessionStorage.setItem.mock.invocationCallOrder[0]) + .toBeLessThan(popup.location.replace.mock.invocationCallOrder[0]) + expect(button().disabled).toBe(true) + }) + + it('keeps the button pending while receive() answers null', async () => { + vi.spyOn(window, 'open').mockReturnValue(fakePopup() as unknown as Window) + receive.mockResolvedValue(null) + const onSuccess = mount() + + await clickSignIn() await settle() - expect(claim).not.toHaveBeenCalled() + await tick() + await tick() - deliver(popup) + expect(receive).toHaveBeenCalledTimes(2) + expect(button().disabled).toBe(true) + expect(localStorage.getItem('authToken')).toBeNull() + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('stores the token receive() releases, closes the popup and stops polling', async () => { + const popup = fakePopup() + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + receive.mockResolvedValueOnce(null).mockResolvedValueOnce(TOKEN) + const onSuccess = mount() + + await clickSignIn() await settle() - expect(claim).toHaveBeenCalledExactlyOnceWith(TICKET) - expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + await tick() + expect(onSuccess).not.toHaveBeenCalled() + await tick() + + expect(localStorage.getItem('authToken')).toBe(TOKEN) expect(onSuccess).toHaveBeenCalledOnce() expect(popup.close).toHaveBeenCalled() + await tick() + await tick() + expect(receive).toHaveBeenCalledTimes(2) }) - it('keeps listening after the popup handle dies, and claims a broadcast ticket', async () => { - // A provider that isolates its pages with COOP severs the opener mid-flow: the handle reports - // closed while the flow is still running, and the handoff page reaches us over the channel. - const severed = { closed: false, close: vi.fn<() => void>() } as unknown as Window - vi.spyOn(window, 'open').mockReturnValue(severed) - claim.mockResolvedValue('alice@example.com:secret') - const rpcStub = { - startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), - } as unknown as RpcStub - const onSuccess = mount(rpcStub) - const button = () => container!.querySelector('button')! + it('shows the failure and hands the buttons back when receive() rejects', async () => { + vi.spyOn(window, 'open').mockReturnValue(fakePopup() as unknown as Window) + receive.mockRejectedValue(new Error('This sign-in attempt has expired.')) + const onSuccess = mount() await clickSignIn() await settle() - expect(button().disabled).toBe(true) + await tick() - ;(severed as { closed: boolean }).closed = true - await act(() => new Promise(resolve => setTimeout(resolve, 600))) - // Not treated as a cancellation: the buttons come back, the attempt stays live. + expect(container!.textContent).toContain('This sign-in attempt has expired.') expect(button().disabled).toBe(false) - expect(container!.textContent).not.toContain('cancelled') - expect(claim).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + await tick() + expect(receive).toHaveBeenCalledOnce() + }) - const sender = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - // The page repeats its broadcast until a Workshop window acknowledges the ticket. - const acked = new Promise(resolve => { - sender.addEventListener('message', (event: MessageEvent) => resolve(event.data), { once: true }) - }) - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await vi.waitFor(() => expect(claim).toHaveBeenCalledExactlyOnceWith(TICKET)) + it('hands the buttons back when the popup reports closed, but keeps polling', async () => { + // The popup closes itself after confirming, and a provider that swaps browsing context groups + // (COOP) reports it closed while the flow is still running: neither is a cancellation. + const popup = fakePopup() + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + receive.mockResolvedValue(null) + const onSuccess = mount() + + await clickSignIn() await settle() - expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + expect(button().disabled).toBe(true) + + popup.closed = true + await tick() + expect(button().disabled).toBe(false) + expect(container!.textContent).not.toMatch(/cancelled|Could not/) + + receive.mockResolvedValue(TOKEN) + await tick() + expect(localStorage.getItem('authToken')).toBe(TOKEN) expect(onSuccess).toHaveBeenCalledOnce() - expect(await acked).toEqual({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket: TICKET }) - sender.close() }) - it('keeps waiting when a broadcast ticket belongs to another attempt', async () => { - // A broadcast has no source to filter on, so the channel may carry another tab's sign-in ticket - // or an account-connect ticket first. The server answers null for those; ours still lands. - const own = { closed: false, close: vi.fn<() => void>() } as unknown as Window - vi.spyOn(window, 'open').mockReturnValue(own) - const FOREIGN = 'f'.repeat(64) - claim.mockImplementation(async ticket => ticket === TICKET ? 'alice@example.com:secret' : null) - const rpcStub = { - startGatekeeperLogin: async () => ({ url: 'https://gk.example/login', attempt }), + it('tears down the first attempt when a second sign-in starts after its popup reported closed', async () => { + // The buttons come back while the first attempt still polls, so a second click is the natural + // next move; only the newest attempt may then be listening, or the abandoned one could land a + // token behind the user's back. + const first = fakePopup() + const second = fakePopup() + vi.spyOn(window, 'open') + .mockReturnValueOnce(first as unknown as Window) + .mockReturnValueOnce(second as unknown as Window) + const receiveFirst = vi.fn<() => Promise>().mockResolvedValue(null) + const receiveSecond = vi.fn<() => Promise>().mockResolvedValue(null) + const disposeFirst = vi.fn<() => void>() + const attempts = [ + { receive: receiveFirst, [Symbol.dispose]: disposeFirst }, + { receive: receiveSecond, [Symbol.dispose]() {} }, + ] + const stub = { + startGatekeeperLogin: async () => ({ url: URL, nonce: NONCE, attempt: attempts.shift() }), } as unknown as RpcStub - const onSuccess = mount(rpcStub) - const button = () => container!.querySelector('button')! + const onSuccess = mount(stub) await clickSignIn() await settle() - const sender = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: FOREIGN }) - await vi.waitFor(() => expect(claim).toHaveBeenCalledExactlyOnceWith(FOREIGN)) + first.closed = true + await tick() + expect(button().disabled).toBe(false) + expect(receiveFirst).toHaveBeenCalledOnce() + + await clickSignIn() await settle() + expect(disposeFirst).toHaveBeenCalledOnce() + expect(second.location.replace).toHaveBeenCalledExactlyOnceWith(URL) + + receiveFirst.mockResolvedValue(TOKEN) + await tick() + await tick() + expect(receiveFirst).toHaveBeenCalledOnce() + expect(receiveSecond).toHaveBeenCalledTimes(2) expect(localStorage.getItem('authToken')).toBeNull() expect(onSuccess).not.toHaveBeenCalled() - expect(container!.textContent).not.toMatch(/expired|verified|Could not/) - expect(button().disabled).toBe(true) - // The foreign claim paused the popup-closed poll; closing the popup now must still hand the - // buttons back rather than leave them stuck until the right ticket arrives. - ;(own as { closed: boolean }).closed = true - await act(() => new Promise(resolve => setTimeout(resolve, 600))) - expect(button().disabled).toBe(false) - expect(container!.textContent).not.toContain('cancelled') + receiveSecond.mockResolvedValue(TOKEN) + await tick() + expect(localStorage.getItem('authToken')).toBe(TOKEN) + expect(onSuccess).toHaveBeenCalledOnce() + expect(second.close).toHaveBeenCalled() + }) - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - sender.postMessage({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - sender.close() - await vi.waitFor(() => expect(claim).toHaveBeenCalledWith(TICKET)) + it('does not re-enter a receive() still in flight', async () => { + vi.spyOn(window, 'open').mockReturnValue(fakePopup() as unknown as Window) + const slow = deferred() + receive.mockReturnValue(slow.promise) + const onSuccess = mount() + + await clickSignIn() await settle() - expect(claim).toHaveBeenCalledTimes(2) - expect(localStorage.getItem('authToken')).toBe('alice@example.com:secret') + await tick() + await tick() + await tick() + expect(receive).toHaveBeenCalledOnce() + + slow.resolve(TOKEN) + await settle() + expect(localStorage.getItem('authToken')).toBe(TOKEN) expect(onSuccess).toHaveBeenCalledOnce() }) it('opens nothing if it was unmounted while the sign-in was starting', async () => { - const open = vi.spyOn(window, 'open').mockReturnValue(popup) - const start = deferred<{ url: string; attempt: RpcStub }>() + const open = vi.spyOn(window, 'open').mockReturnValue(fakePopup() as unknown as Window) + const start = deferred<{ url: string; nonce: string; attempt: RpcStub }>() const dispose = vi.fn<() => void>() - const rpcStub = { + const stub = { startGatekeeperLogin: () => start.promise, } as unknown as RpcStub - mount(rpcStub) + mount(stub) await clickSignIn() act(() => root?.unmount()) root = undefined start.resolve({ - url: 'https://gk.example/login', - attempt: { claim, [Symbol.dispose]: dispose } as unknown as RpcStub, + url: URL, + nonce: NONCE, + attempt: { receive, [Symbol.dispose]: dispose } as unknown as RpcStub, }) await settle() expect(open).not.toHaveBeenCalled() expect(dispose).toHaveBeenCalledOnce() }) + + it('keeps the buttons disabled while a receive() is in flight after the popup reports closed', async () => { + // The server releases the token exactly once. A click that tore down a receive() the server is + // answering would discard that token, so the buttons come back only between calls. + const popup = fakePopup() + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + const slow = deferred() + receive.mockReturnValue(slow.promise) + mount() + + await clickSignIn() + await settle() + await tick() + expect(receive).toHaveBeenCalledOnce() + + popup.closed = true + await tick() + expect(button().disabled).toBe(true) + + slow.resolve(null) + await settle() + receive.mockResolvedValue(null) + await tick() + expect(button().disabled).toBe(false) + }) + + it('a second click during an in-flight receive() lets it finish first', async () => { + // The buttons came back between two polls, and the next receive() is in flight when the user + // clicks again. The new attempt waits for that call; when it releases the token, the first + // attempt completes the login and no second one is started. + const popup = fakePopup() + const open = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) + const slow = deferred() + const dispose = vi.fn<() => void>() + const startGatekeeperLogin = vi.fn<() => Promise<{ url: string; nonce: string; attempt: RpcStub }>>( + async () => ({ + url: URL, + nonce: NONCE, + attempt: { receive, [Symbol.dispose]: dispose } as unknown as RpcStub, + })) + const stub = { startGatekeeperLogin } as unknown as RpcStub + receive.mockResolvedValueOnce(null).mockReturnValue(slow.promise) + const onSuccess = mount(stub) + + await clickSignIn() + await settle() + popup.closed = true + await tick() + expect(button().disabled).toBe(false) + await tick() + expect(receive).toHaveBeenCalledTimes(2) + + await clickSignIn() + await settle() + expect(button().disabled).toBe(true) + expect(dispose).not.toHaveBeenCalled() + expect(startGatekeeperLogin).toHaveBeenCalledOnce() + + slow.resolve(TOKEN) + await settle() + await settle() + + expect(localStorage.getItem('authToken')).toBe(TOKEN) + expect(onSuccess).toHaveBeenCalledOnce() + expect(open).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(startGatekeeperLogin).toHaveBeenCalledOnce() + await tick() + expect(receive).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx b/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx index e15df97a8d..304c043c2b 100644 --- a/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx +++ b/packages/workshop-frontend/src/components/auth/OAuthButtons.tsx @@ -1,11 +1,8 @@ import { useEffect, useRef, useState } from 'react' import { RpcStub } from 'capnweb' import { PublicApi, AuthVendorInfo } from '@gadgets/workshop-shared/api' -import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, -} from '@gadgets/workshop-shared/gatekeeper' import { Button, Banner } from '@cloudflare/kumo' -import { connectHandoffTicket, parseHandoffEnvelope } from '../../connectHandoff' +import { openDisownedPopup, uniquePopupName } from '../../connectHandoff' interface OAuthButtonsProps { rpcStub: RpcStub @@ -17,29 +14,35 @@ interface OAuthButtonsProps { // attempt) rather than failing: the caller then has no state to update. const CANCELLED = Symbol('sign-in cancelled') +// How often the login attempt is asked whether its token has been released. +const RECEIVE_POLL_MS = 1000 + /** - * Renders a sign-in button per auth-capable gatekeeper vendor. Clicking opens the gatekeeper's - * OAuth popup with this window as its opener; when the flow finishes, the popup delivers a handoff - * ticket back here, which is redeemed over RPC for the session token. The ticket is what ties the - * session to this browser: the sign-in URL alone can be finished by anyone (see connectHandoff.ts). - * On success the token is stored and the app re-authenticates. + * Renders a sign-in button per auth-capable gatekeeper vendor. Clicking starts a login attempt and + * opens the gatekeeper's OAuth URL as a disowned popup carrying the attempt's nonce in its own + * sessionStorage (see connectHandoff.ts). When the flow finishes, the gatekeeper's final page lands + * the popup on our /connect/handoff page, which confirms the single-use ticket together with the + * nonce over the public API. That confirmation is what ties the session to this browser: the + * sign-in URL alone can be finished by anyone. This component meanwhile polls + * `attempt.receive()`, which releases the session token only once the ticket is confirmed and only + * to the holder of the `attempt` capability; the popup never sees the token. On success the token + * is stored and the app re-authenticates. * - * The ticket arrives over one of two transports. Normally the popup posts it to its opener. A - * provider that isolates its pages with COOP severs that opener mid-flow, though (Google stages - * this), and the handoff page then falls back to a same-origin BroadcastChannel — which reaches us - * because in production the login page shares an origin with the handoff page. A broadcast has no - * source to filter on, so a ticket heard there may be another tab's sign-in or an account-connect - * ticket; the server answers such a claim with null, and we keep listening for ours. + * A newer attempt waits for the previous one's in-flight `receive()` before tearing it down: the + * server releases the token exactly once, so a call cancelled mid-flight could discard a token + * that had already been handed out, leaving the user to sign in twice. */ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButtonsProps) { const [error, setError] = useState(null) const [pending, setPending] = useState(null) - // The attempt in flight, if any, as the function that tears it down: stops the popup poll, drops - // both ticket listeners and disposes the login RPC (Cap'n Web treats this as a best-effort cancel - // and frees the client-side pending call). Run when the component unmounts mid-login (e.g. the - // user navigates away) and when a new attempt starts, so at most one attempt is ever listening. - const attemptRef = useRef<(() => void) | null>(null) + // The attempt in flight, if any, as the function that tears it down: waits for a receive() still + // in flight, then stops the poll and disposes the login RPC (Cap'n Web treats this as a + // best-effort cancel and frees the client-side pending call). Resolves to whether the attempt + // ended up receiving the token, in which case its own continuation completes the login. Run when + // the component unmounts mid-login (e.g. the user navigates away) and when a new attempt starts, + // so at most one attempt is ever polling. + const attemptRef = useRef<(() => Promise) | null>(null) const mountedRef = useRef(true) useEffect(() => { // Re-assert on (re)mount: under StrictMode the effect runs mount→cleanup→mount, and the cleanup @@ -49,7 +52,7 @@ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButto mountedRef.current = true return () => { mountedRef.current = false - attemptRef.current?.() + void attemptRef.current?.() attemptRef.current = null } }, []) @@ -57,13 +60,17 @@ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButto if (vendors.length === 0) return null const start = async (vendorId: string) => { - attemptRef.current?.() - attemptRef.current = null + // Disable the buttons at once, then let the previous attempt finish a receive() it may have in + // flight: if that call releases the token, the previous attempt completes the login and this + // one has nothing to do. + setPending(vendorId) + const previousReceived = await attemptRef.current?.() + if (!mountedRef.current || previousReceived) return setError(null) setPending(vendorId) try { - const { url, attempt } = await rpcStub.startGatekeeperLogin(vendorId) - // `attempt` is the capability to redeem the session token. + const { url, nonce, attempt } = await rpcStub.startGatekeeperLogin(vendorId) + // `attempt` is the capability to receive the session token. const dispose = () => { try { (attempt as unknown as Disposable)[Symbol.dispose]() } catch { /* already disposed */ } } @@ -73,90 +80,59 @@ export default function OAuthButtons({ rpcStub, vendors, onSuccess }: OAuthButto dispose() return } - // Unlike account-connect popups (see openConnectWindow), a login popup deliberately keeps this - // window as its opener: sign-in providers are admin-allowlisted, and the opener is how the - // ticket normally comes back (postMessage). Don't pass "noopener" — window.open() returns null - // with it, indistinguishable from a pop-up block. - const popup = window.open(url, 'gatekeeper-login', 'popup,width=520,height=680') - if (!popup) { + // Disowned like connect popups: sign-in providers are admin-allowlisted, but the popup + // traverses provider pages all the same, and none of them gets a handle on this tab. The + // nonce rides along in the popup's own storage for the handoff page to present. + let popup: Window + try { + popup = openDisownedPopup(url, uniquePopupName('gatekeeper-login'), { kind: 'login', nonce }) + } catch (err) { dispose() - throw new Error('Pop-up blocked. Please allow pop-ups and try again.') + throw err } - // Resolve once a ticket arrives and the claim succeeds; reject if the claim fails or the - // attempt is torn down. + // Resolve once the attempt releases the token; reject if it fails or is torn down. const token = await new Promise((resolve, reject) => { let settled = false - let poll: number | null = null - const channel = 'BroadcastChannel' in globalThis - ? new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - : null - - function stopPolling() { - if (poll !== null) { clearInterval(poll); poll = null } - } - // An arrow, not a declaration: only a closure created after the null check above sees - // `popup` narrowed. - const startPolling = () => { - if (poll !== null) return - poll = window.setInterval(() => { - if (!popup.closed) return - // Not necessarily a cancellation: a provider that swaps browsing context groups (COOP) - // reports the popup closed while the flow is still running, and its ticket will arrive - // over the channel. So just hand the buttons back and keep listening; if the user really - // closed it, nothing arrives and the attempt ends with the next one or on unmount. - stopPolling() - if (mountedRef.current) setPending(null) - }, 500) - } + let received = false + // The receive() call in flight, if any, as a promise that settles once its outcome has been + // handled here; never rejects. + let inflight: Promise | null = null + const poll = window.setInterval(() => { + // Not necessarily a cancellation: the popup closes itself after confirming, and a + // provider that swaps browsing context groups (COOP) reports it closed while the flow is + // still running. So just hand the buttons back and keep polling. If the user really + // closed it, nothing arrives: the poll ends with the next attempt, on unmount, or when + // the attempt expires server-side, which then shows as the expiry error. The buttons + // stay disabled while a receive() is in flight, though: a second click at that moment + // would tear down a call the server may be answering with the token. + if (popup.closed && inflight === null && mountedRef.current) setPending(null) + // A receive() still in flight is not re-entered. + if (inflight !== null) return + inflight = attempt.receive() + .then(t => { + inflight = null + if (t !== null) { + received = true + finish(() => resolve(t)) + } + }) + .catch(e => finish(() => reject(e instanceof Error ? e : new Error('Could not sign in')))) + }, RECEIVE_POLL_MS) function finish(fn: () => void) { if (settled) return settled = true attemptRef.current = null - stopPolling() - window.removeEventListener('message', onMessage) - channel?.close() + clearInterval(poll) dispose() fn() } - // Claims may overlap: a foreign ticket answered with null must not hold up the real one - // behind it, and `finish` settles only once. Polling pauses during a claim so a popup that - // closes itself on completion is not read as a cancellation, and resumes after a foreign - // ticket, or closing the popup afterwards would leave the buttons stuck. - function claimTicket(ticket: string) { - if (settled) return - stopPolling() - attempt.claim(ticket) - .then(t => { - if (settled) return - if (t === null) { - startPolling() - return - } - // A popup whose opener COOP severed broadcasts, and repeats until acknowledged. - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - channel?.postMessage({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket }) - finish(() => resolve(t)) - }) - .catch(e => finish(() => reject(e instanceof Error ? e : new Error('Could not sign in')))) - } - function onMessage(event: MessageEvent) { - // Unlike the connect listener, this page holds the popup handle, so a ticket from any - // other window (say, an account-connect popup that outlived a logout) is not ours: claiming - // it would only burn this attempt. - if (event.source !== popup) return - const ticket = connectHandoffTicket(event) - if (ticket !== null) claimTicket(ticket) + attemptRef.current = async () => { + await inflight + if (!settled) finish(() => reject(CANCELLED)) + return received } - - window.addEventListener('message', onMessage) - channel?.addEventListener('message', (event: MessageEvent) => { - const ticket = parseHandoffEnvelope(event.data) - if (ticket !== null) claimTicket(ticket) - }) - startPolling() - attemptRef.current = () => finish(() => reject(CANCELLED)) }) - // Best-effort: after a COOP swap the handle is dead, and the page closes itself anyway. + // Best-effort: the page closes itself anyway, and a COOP swap leaves the handle dead. try { popup.close() } catch { /* severed */ } if (!mountedRef.current) return // user navigated away mid-flow; drop the result localStorage.setItem('authToken', token) diff --git a/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx b/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx index f5f8cf7393..d040423918 100644 --- a/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx +++ b/packages/workshop-frontend/src/components/billing/OutOfCreditsModal.tsx @@ -61,8 +61,7 @@ export default function OutOfCreditsModal({ open, onClose }: OutOfCreditsModalPr if (!auth) return setConnecting(true) try { - const { url } = await auth.authenticatedApi.connectAccount('cloudflare', []) - openConnectWindow(url) + openConnectWindow(await auth.authenticatedApi.connectAccount('cloudflare', [])) } catch (err) { toasts.add({ title: 'Failed to start Cloudflare connection', diff --git a/packages/workshop-frontend/src/components/billing/UsageSettings.tsx b/packages/workshop-frontend/src/components/billing/UsageSettings.tsx index b8525fc4b3..8d508ffb68 100644 --- a/packages/workshop-frontend/src/components/billing/UsageSettings.tsx +++ b/packages/workshop-frontend/src/components/billing/UsageSettings.tsx @@ -60,9 +60,9 @@ export default function UsageSettings() { setBusy(true) try { // Connecting (or signing in with) Cloudflare is handled by the Cloudflare gatekeeper. Open its - // OAuth popup; the connected-accounts subscription + focus refresh pick up the result. - const { url } = await authenticatedApi.connectAccount('cloudflare', []) - openConnectWindow(url) + // OAuth popup; the popup redeems the ticket itself, and the account arrives through the + // accounts subscription (plus the focus refresh). + openConnectWindow(await authenticatedApi.connectAccount('cloudflare', [])) } catch { toasts.add({ title: 'Failed to start Cloudflare connection', variant: 'error' }) } finally { diff --git a/packages/workshop-frontend/src/connectHandoff.test.tsx b/packages/workshop-frontend/src/connectHandoff.test.tsx index 40937e301e..ae5db45771 100644 --- a/packages/workshop-frontend/src/connectHandoff.test.tsx +++ b/packages/workshop-frontend/src/connectHandoff.test.tsx @@ -1,296 +1,176 @@ // @vitest-environment jsdom -/* eslint-disable react/react-in-jsx-scope */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcStub } from 'capnweb' -import type { AuthenticatedApi } from '@gadgets/workshop-shared/api' import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, -} from '@gadgets/workshop-shared/gatekeeper' -import { gatekeeperOrigin, openConnectWindow, useConnectHandoffListener } from './connectHandoff' - -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + HANDOFF_KEY, HANDOFF_PATH, openConnectWindow, readPopupHandoff, ticketFromHandoffFragment, + uniquePopupName, +} from './connectHandoff' +import { createRouter } from './router' const TICKET = 'a'.repeat(64) - -function Listener({ api, onError }: { api: RpcStub | null; onError: (m: string) => void }) { - useConnectHandoffListener(api, onError) - return null -} - -function deliver(data: unknown, origin = gatekeeperOrigin(), source: Window | null = null) { - window.dispatchEvent(new MessageEvent('message', { data, origin, source })) -} - -// Lets the RPC promise settle and React flush. -const settle = () => act(async () => { await Promise.resolve(); await Promise.resolve() }) - -// What a disowned popup on our origin does: broadcast on the channel named after the message type. -// Delivery is asynchronous, so callers wait a tick before asserting. -async function broadcast(...messages: unknown[]) { - const channel = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - for (const message of messages) channel.postMessage(message) - await new Promise(resolve => setTimeout(resolve, 20)) - channel.close() -} - -// What `openConnectWindow` leaves behind in this tab: the marker that makes a broadcast ticket ours, -// stamped with when the popup was opened. -const CONNECT_PENDING_KEY = 'gadgets.connectPending' -const pending = (openedAt = Date.now()) => sessionStorage.setItem(CONNECT_PENDING_KEY, String(openedAt)) - -// The next acknowledgement posted on the channel, as the handoff page hears it (the page's own -// broadcasts pass this receiver too, so anything but an ack is skipped). -function nextAck(): Promise { - const receiver = new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - return new Promise(resolve => { - receiver.addEventListener('message', (event: MessageEvent) => { - if (event.data?.type !== CONNECT_HANDOFF_ACK_MESSAGE_TYPE) return - receiver.close() - resolve(event.data) - }) - }) -} - -describe('useConnectHandoffListener', () => { - let root: Root | undefined - let container: HTMLDivElement | undefined - const completeConnectHandoff = vi.fn<(ticket: string) => Promise>() - const onError = vi.fn<(message: string) => void>() - const api = { completeConnectHandoff } as unknown as RpcStub - - function mount() { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - act(() => root!.render()) +const NONCE = 'b'.repeat(64) +const FEATURES = 'popup,width=520,height=680' + +// A popup as window.open returns it: an opener pointing back at us, its own storage, and a location +// to navigate. `openerAtReplace` records what the opener was when the navigation happened. +function fakePopup() { + const store = new Map() + const popup = { + opener: window as Window | null, + openerAtReplace: undefined as Window | null | undefined, + close: vi.fn<() => void>(), + sessionStorage: { + store, + setItem: vi.fn<(key: string, value: string) => void>((key, value) => { store.set(key, value) }), + }, + location: { + replace: vi.fn<(url: string) => void>(() => { popup.openerAtReplace = popup.opener }), + }, } + return popup +} +describe('openConnectWindow', () => { afterEach(() => { - act(() => root?.unmount()) - container?.remove() vi.restoreAllMocks() - completeConnectHandoff.mockReset() - onError.mockReset() sessionStorage.clear() }) - it('redeems a well-formed ticket from the gatekeeper origin and closes the popup', async () => { - completeConnectHandoff.mockResolvedValue(undefined) - const popup = { close: vi.fn<() => void>() } as unknown as Window - mount() - - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, gatekeeperOrigin(), popup) - await settle() - - expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) - expect(popup.close).toHaveBeenCalledOnce() - expect(onError).not.toHaveBeenCalled() - }) - - it('redeems a ticket broadcast on the same-origin channel, closing nothing itself', async () => { - // A disowned popup on our own origin has no opener to post to; it broadcasts and closes itself. - completeConnectHandoff.mockResolvedValue(undefined) - pending() - mount() - - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - - expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) - expect(onError).not.toHaveBeenCalled() - }) - - it('ignores a broadcast ticket when this tab opened no connect', async () => { - // Every Workshop tab on the origin hears the channel; only the one that opened the popup redeems, - // so the others neither race it nor toast that the attempt expired. - mount() - - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() + it('opens an empty popup under a fresh name, disowns it, gives it the nonce, then navigates it', () => { + const popup = fakePopup() + const open = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) - expect(completeConnectHandoff).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() + expect(openConnectWindow({ url: 'https://gk.example/connect', nonce: NONCE })).toBe(popup) + expect(open).toHaveBeenCalledExactlyOnceWith('', expect.stringMatching(/^gadgets-connect-/), FEATURES) + expect(open.mock.calls[0][1]).not.toContain('noopener') + expect(open.mock.calls[0][2]).not.toContain('noopener') + // Disowned before it is navigated, so no page in the flow ever sees window.opener. + expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/connect') + expect(popup.openerAtReplace).toBeNull() + expect(popup.opener).toBeNull() + // The nonce is written while the popup is still our about:blank, before the navigation. + expect(popup.sessionStorage.setItem.mock.invocationCallOrder[0]) + .toBeLessThan(popup.location.replace.mock.invocationCallOrder[0]) }) - it('spends the marker on a successful broadcast redemption and acknowledges it', async () => { - completeConnectHandoff.mockResolvedValue(undefined) - const popup = { opener: null, location: { replace: vi.fn<(url: string) => void>() } } + it('writes the handoff record into the popup, and nothing into this tab', () => { + const popup = fakePopup() vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) - const before = Date.now() - mount() - openConnectWindow('https://gk.example/connect') - expect(Number(sessionStorage.getItem(CONNECT_PENDING_KEY))).toBeGreaterThanOrEqual(before) - const heard = nextAck() - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) - // Spent: a later broadcast is not this tab's. The ack is what stops the page repeating. - expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).toBeNull() - expect(await heard).toEqual({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket: TICKET }) - expect(onError).not.toHaveBeenCalled() + openConnectWindow({ url: 'https://gk.example/connect', nonce: NONCE }) - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'b'.repeat(64) }) - await settle() - expect(completeConnectHandoff).toHaveBeenCalledOnce() + expect(popup.sessionStorage.setItem).toHaveBeenCalledExactlyOnceWith( + HANDOFF_KEY, JSON.stringify({ kind: 'connect', nonce: NONCE })) + expect(JSON.parse(popup.sessionStorage.store.get(HANDOFF_KEY)!)).toEqual({ kind: 'connect', nonce: NONCE }) + expect(sessionStorage.length).toBe(0) }) - it('keeps the marker when a broadcast redemption fails, and tries each ticket once', async () => { - // A sibling tab's or a sign-in ticket heard first is rejected by the server; that must not - // cost this tab its own ticket, which is still on its way. The page repeats its broadcast until - // acked, so a ticket already tried is ignored rather than toasted again. - completeConnectHandoff - .mockRejectedValueOnce(new Error('This connection attempt has expired.')) - .mockResolvedValueOnce(undefined) - pending() - mount() + it('closes the previous connect popup and names the next one differently', () => { + const first = fakePopup() + const second = fakePopup() + const open = vi.spyOn(window, 'open') + .mockReturnValueOnce(first as unknown as Window) + .mockReturnValueOnce(second as unknown as Window) - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - expect(completeConnectHandoff).toHaveBeenCalledExactlyOnceWith(TICKET) - expect(onError).toHaveBeenCalledExactlyOnceWith('This connection attempt has expired.') - expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).not.toBeNull() + openConnectWindow({ url: 'https://gk.example/one', nonce: NONCE }) + expect(first.close).not.toHaveBeenCalled() + openConnectWindow({ url: 'https://gk.example/two', nonce: 'c'.repeat(64) }) - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - expect(completeConnectHandoff).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledOnce() - - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'b'.repeat(64) }) - await settle() - expect(completeConnectHandoff).toHaveBeenCalledTimes(2) - expect(completeConnectHandoff).toHaveBeenLastCalledWith('b'.repeat(64)) - expect(sessionStorage.getItem(CONNECT_PENDING_KEY)).toBeNull() + expect(first.close).toHaveBeenCalledOnce() + expect(second.close).not.toHaveBeenCalled() + expect(open.mock.calls[0][1]).not.toBe(open.mock.calls[1][1]) + expect(second.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/two') }) - it('ignores a marker older than the connect lifetime', async () => { - // An abandoned popup's flow can no longer complete once its connect and OAuth nonces have both - // expired, so its marker must stop this tab racing its siblings for their tickets. - pending(Date.now() - 31 * 60 * 1000) - mount() - - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() + it('tells the user when the browser blocked the popup', () => { + vi.spyOn(window, 'open').mockReturnValue(null) - expect(completeConnectHandoff).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() + expect(() => openConnectWindow({ url: 'https://gk.example/connect', nonce: NONCE })) + .toThrow('Pop-up blocked. Please allow pop-ups and try again.') }) - it('ignores a malformed broadcast', async () => { - pending() - mount() - - await broadcast( - { type: 'gadgets.connect-handoff.v0', ticket: TICKET }, - { type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'not-a-ticket' }, - 'ticket', - ) - await settle() + it('closes the popup and throws when it refuses the storage write, starting nothing', () => { + // Without the nonce the flow could never complete, so the user hears it now, not after the + // provider's consent screen. + const popup = fakePopup() + popup.sessionStorage.setItem.mockImplementation(() => { throw new DOMException('denied', 'SecurityError') }) + vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) - expect(completeConnectHandoff).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() + expect(() => openConnectWindow({ url: 'https://gk.example/connect', nonce: NONCE })) + .toThrow(/blocks storage in pop-ups/) + expect(popup.close).toHaveBeenCalledOnce() + expect(popup.location.replace).not.toHaveBeenCalled() }) +}) - it('ignores messages from any other origin, type, or shape', async () => { - mount() - - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, 'https://evil.example') - deliver({ type: 'gadgets.connect-handoff.v0', ticket: TICKET }) - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: 'not-a-ticket' }) - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET.toUpperCase() }) - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE }) - deliver('ticket') - deliver(null) - await settle() - - expect(completeConnectHandoff).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() +describe('uniquePopupName', () => { + it('names each popup with a random suffix, not a counter', () => { + const first = uniquePopupName('gadgets-connect') + const second = uniquePopupName('gadgets-connect') + expect(first).toMatch(/^gadgets-connect-[0-9a-f-]{36}$/) + expect(second).toMatch(/^gadgets-connect-[0-9a-f-]{36}$/) + expect(second).not.toBe(first) }) +}) - it('reports a rejected ticket and leaves the popup open', async () => { - completeConnectHandoff.mockRejectedValue(new Error('This connection attempt has expired.')) - const popup = { close: vi.fn<() => void>() } as unknown as Window - mount() +describe('readPopupHandoff', () => { + afterEach(() => { sessionStorage.clear() }) - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }, gatekeeperOrigin(), popup) - await settle() + it('returns the record and removes it', () => { + sessionStorage.setItem(HANDOFF_KEY, JSON.stringify({ kind: 'login', nonce: NONCE })) - expect(onError).toHaveBeenCalledExactlyOnceWith('This connection attempt has expired.') - expect(popup.close).not.toHaveBeenCalled() + expect(readPopupHandoff()).toEqual({ kind: 'login', nonce: NONCE }) + expect(sessionStorage.getItem(HANDOFF_KEY)).toBeNull() + expect(readPopupHandoff()).toBeNull() }) - it('listens for nothing when given no session', async () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - act(() => root!.render()) + it.each([ + ['malformed JSON', '{kind:'], + ['an unknown kind', JSON.stringify({ kind: 'reconnect', nonce: NONCE })], + ['a non-hex nonce', JSON.stringify({ kind: 'connect', nonce: 'g'.repeat(64) })], + ['an uppercase nonce', JSON.stringify({ kind: 'connect', nonce: 'B'.repeat(64) })], + ['a short nonce', JSON.stringify({ kind: 'connect', nonce: 'b'.repeat(63) })], + ['a missing nonce', JSON.stringify({ kind: 'connect' })], + ['a non-object', JSON.stringify('connect')], + ])('returns null and removes the key for %s', (_label, stored) => { + sessionStorage.setItem(HANDOFF_KEY, stored) - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - - expect(completeConnectHandoff).not.toHaveBeenCalled() + expect(readPopupHandoff()).toBeNull() + expect(sessionStorage.getItem(HANDOFF_KEY)).toBeNull() }) - it('stops listening once unmounted, on both transports', async () => { - pending() - mount() - act(() => root?.unmount()) - root = undefined - - deliver({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await broadcast({ type: CONNECT_HANDOFF_MESSAGE_TYPE, ticket: TICKET }) - await settle() - - expect(completeConnectHandoff).not.toHaveBeenCalled() + it('returns null when nothing is stored', () => { + expect(readPopupHandoff()).toBeNull() }) }) -describe('openConnectWindow', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllEnvs() +describe('ticketFromHandoffFragment', () => { + it('accepts a 64-hex ticket with or without the leading #', () => { + expect(ticketFromHandoffFragment(`#${TICKET}`)).toBe(TICKET) + expect(ticketFromHandoffFragment(TICKET)).toBe(TICKET) }) - // A popup as window.open returns it: an opener pointing back at us, and a location to navigate. - function fakePopup() { - return { - opener: window as Window | null, - location: { replace: vi.fn<(url: string) => void>() }, - } - } - - it('opens an empty popup, disowns it, then navigates it when the gatekeepers share our origin', () => { - vi.stubEnv('VITE_BACKEND_HOST', window.location.host) - const popup = fakePopup() - const open = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) - - expect(openConnectWindow('https://gk.example/connect')).toBe(popup) - expect(open).toHaveBeenCalledExactlyOnceWith('', 'gadgets-connect', 'popup,width=520,height=680') - expect(open.mock.calls[0][2]).not.toContain('noopener') - // Disowned before it is navigated, so no provider page ever sees window.opener. - expect(popup.opener).toBeNull() - expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/connect') + it('decodes a percent-encoded ticket', () => { + expect(ticketFromHandoffFragment(`#${encodeURIComponent(TICKET)}`)).toBe(TICKET) + expect(ticketFromHandoffFragment(`#%61${'a'.repeat(63)}`)).toBe(TICKET) }) - it('keeps the opener when the gatekeepers are on another origin, as under the dev server', () => { - // A BroadcastChannel could not cross origins, so the page must be able to postMessage to us. - expect(gatekeeperOrigin()).not.toBe(window.location.origin) - const popup = fakePopup() - vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) - - expect(openConnectWindow('https://gk.example/connect')).toBe(popup) - expect(popup.opener).toBe(window) - expect(popup.location.replace).toHaveBeenCalledExactlyOnceWith('https://gk.example/connect') + it.each([ + ['uppercase hex', `#${TICKET.toUpperCase()}`], + ['63 characters', `#${'a'.repeat(63)}`], + ['65 characters', `#${'a'.repeat(65)}`], + ['a malformed escape', '#%zz'], + ['an empty string', ''], + ['a bare #', '#'], + ])('rejects %s', (_label, hash) => { + expect(ticketFromHandoffFragment(hash)).toBeNull() }) +}) - it('tells the user when the browser blocked the popup', () => { - vi.spyOn(window, 'open').mockReturnValue(null) - - expect(() => openConnectWindow('https://gk.example/connect')) - .toThrow('Pop-up blocked. Please allow pop-ups and try again.') +describe('HANDOFF_PATH', () => { + it('is the path the kit navigates a finished popup to, and one the SPA routes', () => { + expect(HANDOFF_PATH).toBe('/connect/handoff') + // The real route tree, so a renamed or missing routes/connect.handoff.tsx fails here. + expect(createRouter().routesByPath[HANDOFF_PATH]).toBeDefined() }) }) diff --git a/packages/workshop-frontend/src/connectHandoff.ts b/packages/workshop-frontend/src/connectHandoff.ts index c0add140c5..9d101eb384 100644 --- a/packages/workshop-frontend/src/connectHandoff.ts +++ b/packages/workshop-frontend/src/connectHandoff.ts @@ -1,15 +1,11 @@ // The browser half of the gatekeeper connect handoff (see `GatekeeperVendor.connectAccount` in -// workshop-shared). A connect URL is a bearer capability, so the Workshop opens it as a popup; when -// the flow finishes, the gatekeeper's page delivers a single-use ticket back here — over a -// same-origin BroadcastChannel, or by postMessage to its opener where one is kept — and redeeming it -// over our authenticated session is what activates the grant. +// workshop-shared). A connect URL is a bearer capability, so the Workshop opens it as a disowned +// popup carrying the flow's nonce in the popup's own sessionStorage. When the flow finishes, the +// gatekeeper's final page navigates that popup to HANDOFF_PATH on this origin with the single-use +// ticket in the URL fragment, and ConnectHandoffPage redeems ticket and nonce together over the +// popup's own session. Redeeming is what activates the grant. -import { useEffect } from 'react' -import type { RpcStub } from 'capnweb' -import type { AuthenticatedApi } from '@gadgets/workshop-shared/api' -import { - CONNECT_HANDOFF_ACK_MESSAGE_TYPE, CONNECT_HANDOFF_MESSAGE_TYPE, -} from '@gadgets/workshop-shared/gatekeeper' +import type { ConnectFlowStart } from '@gadgets/workshop-shared/api' /** Host the backend (and, through the router, every gatekeeper) is served from. */ export function getBackendHost(): string { @@ -22,165 +18,126 @@ export function getBackendHost(): string { } /** - * Origin the handoff message arrives from: the gatekeeper connect pages are served under - * `/gatekeeper/*` on the backend host, so in production this is the Workshop's own origin. + * Path on the Workshop origin a finished connect / sign-in popup lands on, with the ticket in the + * URL fragment. gatekeeper-kit duplicates the literal, since it must not depend on this package; + * each package pins it with a test. */ -export function gatekeeperOrigin(): string { - return `${window.location.protocol}//${getBackendHost()}` -} +export const HANDOFF_PATH = '/connect/handoff' -const TICKET_PATTERN = /^[0-9a-f]{64}$/ +const HEX_256_PATTERN = /^[0-9a-f]{64}$/ /** - * The ticket a handoff envelope carries, or null unless `data` is a well-formed one. Origin is the - * caller's business: a `message` event's must be checked (`connectHandoffTicket`), a BroadcastChannel - * is same-origin by construction. + * The ticket a handoff URL fragment carries (`window.location.hash`, with or without its leading + * '#', percent-encoded or not), or null unless it decodes to 64 lowercase hex characters. */ -export function parseHandoffEnvelope(data: unknown): string | null { - if (typeof data !== 'object' || data === null) return null - const { type, ticket } = data as { type?: unknown; ticket?: unknown } - if (type !== CONNECT_HANDOFF_MESSAGE_TYPE) return null - if (typeof ticket !== 'string' || !TICKET_PATTERN.test(ticket)) return null - return ticket +export function ticketFromHandoffFragment(hash: string): string | null { + const encoded = hash.startsWith('#') ? hash.slice(1) : hash + let ticket: string + try { + ticket = decodeURIComponent(encoded) + } catch { + return null + } + return HEX_256_PATTERN.test(ticket) ? ticket : null } +/** sessionStorage key under which the Workshop writes a `PopupHandoff` into a popup it opened. */ +export const HANDOFF_KEY = 'gadgets.handoff' + /** - * The ticket a `message` event carries, or null unless it came from the gatekeeper origin with a - * well-formed handoff envelope. Shared by the connect listener and the sign-in buttons, so both apply - * exactly the same checks. + * The record the Workshop tab writes into a popup's own sessionStorage before navigating it: which + * kind of flow the popup runs, and the flow's nonce, which the handoff page presents with the + * ticket (`completeConnectHandoff` for a connect, `confirmLogin` for a sign-in). */ -export function connectHandoffTicket(event: MessageEvent): string | null { - if (event.origin !== gatekeeperOrigin()) return null - return parseHandoffEnvelope(event.data) -} +export type PopupHandoff = { kind: 'connect' | 'login'; nonce: string } /** - * Opens a connect / reconnect / ensure-resources URL as a popup. The popup is opened empty, disowned, - * and only then navigated, so the provider's pages never hold `window.opener`: a connect flow can - * land on pages the deployment does not vouch for — notably an MCP server the user pasted — and an + * Opens `url` as a popup that holds `handoff` and nothing else of this tab. The popup is opened + * empty (a same-origin about:blank, so its sessionStorage is ours to write), disowned, given the + * nonce, and only then navigated, so no page in the flow ever holds `window.opener`: a connect flow + * can land on pages the deployment does not vouch for (an MCP server the user pasted, say), and an * opener handle would let such a page navigate this authenticated tab to a phishing page (reverse - * tabnabbing). Disowning is done by hand rather than with the `noopener` feature because that makes - * `window.open()` return null even on success, which is indistinguishable from a pop-up block. The - * completion page reaches us over a same-origin BroadcastChannel instead (`useConnectHandoffListener`). + * tabnabbing). With no opener in play the flow is also indifferent to a provider isolating its + * pages with COOP. + * + * The nonce goes into the popup's storage, not this tab's: it then exists only on the server and + * in that popup, nothing opened from this tab inherits it, and a handoff link opened any other way + * (a fresh tab, a pasted URL, a link an attacker sends) holds none and redeems nothing. + * + * Disowning is done by hand rather than with the `noopener` feature, which makes `window.open()` + * return null even on success, indistinguishable from a pop-up block. `name` must be fresh per + * flow: `window.open('', existingName)` returns an existing window without navigating it, and one + * parked on a provider page is cross-origin, so the storage write would throw. * - * Under the Vite dev server the Workshop and the gatekeepers are on different origins, so a channel - * could not reach us; there the popup keeps its opener and the page falls back to `postMessage`. A - * provider that isolates its pages with COOP severs that opener too, and no channel crosses origins, - * so such a connect ends in dev on "couldn't reach the Workshop"; production is unaffected, the - * popup being disowned there anyway. Throws when the browser blocked the popup. + * Throws when the browser blocked the popup, or refused the storage write: without the nonce the + * flow could never complete, so it is not started, and the popup is closed again. */ -export function openConnectWindow(url: string): Window { - const popup = window.open('', 'gadgets-connect', 'popup,width=520,height=680') +export function openDisownedPopup(url: string, name: string, handoff: PopupHandoff): Window { + const popup = window.open('', name, 'popup,width=520,height=680') if (!popup) throw new Error('Pop-up blocked. Please allow pop-ups and try again.') - if (gatekeeperOrigin() === window.location.origin) popup.opener = null - markConnectPending() + popup.opener = null + try { + popup.sessionStorage.setItem(HANDOFF_KEY, JSON.stringify(handoff)) + } catch { + popup.close() + throw new Error('This browser blocks storage in pop-ups, so the flow cannot complete. Allow site data for this site and try again.') + } popup.location.replace(url) return popup } /** - * Set in this tab's `sessionStorage` by `openConnectWindow`, so `useConnectHandoffListener` knows a - * broadcast ticket is one this tab asked for. Per-tab and reload-stable, which is exactly the scope - * wanted: the tab that opened the popup redeems, its siblings stay quiet. Holds the time it was - * set, so an abandoned popup's marker ages out instead of racing sibling tabs forever. + * A window name no popup this origin still has open can share: `-`. A per-document + * counter would restart on reload while an earlier disowned popup, still parked on a provider + * page, keeps its name, and `window.open('', thatName)` would hand that cross-origin window back. */ -const CONNECT_PENDING_KEY = 'gadgets.connectPending' +export function uniquePopupName(prefix: string): string { + return `${prefix}-${crypto.randomUUID()}` +} + +// The connect popup this document opened last, closed before the next one opens: a stale popup +// still parked on a provider page is otherwise left behind the new one. +let lastConnectPopup: Window | null = null /** - * How long a marker counts. A ticket can legitimately arrive up to the sum of the gatekeepers' - * initiation-nonce lifetime (10 min, e.g. spent on an endpoint form), the fresh OAuth-nonce lifetime - * (10 min, spent at the consent screen) and the Workshop's handoff lifetime (2 min) after the popup - * opened; anything later cannot be this tab's. Rounded up: the bound exists only so an abandoned - * popup's marker does not race sibling tabs forever. + * Opens a connect / reconnect / ensure-resources flow as a disowned popup carrying the flow's + * nonce (see `openDisownedPopup`). The popup redeems the ticket itself on ConnectHandoffPage; the + * account arrives in this tab through `subscribeConnectedAccounts()`. Throws when the browser + * blocked the popup. */ -const CONNECT_PENDING_LIFETIME_MS = 30 * 60 * 1000 - -// Storage can be unavailable (a disabled cookie jar, a sandboxed frame); every access degrades to -// today's behaviour of redeeming whatever arrives rather than failing the connect. -function markConnectPending(): void { - try { sessionStorage.setItem(CONNECT_PENDING_KEY, String(Date.now())) } catch { /* fall back to redeeming all */ } -} - -function hasPendingConnect(): boolean { - try { - const marked = sessionStorage.getItem(CONNECT_PENDING_KEY) - return marked !== null && Date.now() - Number(marked) < CONNECT_PENDING_LIFETIME_MS - } catch { - return true +export function openConnectWindow(flow: ConnectFlowStart): Window { + if (lastConnectPopup) { + try { lastConnectPopup.close() } catch { /* cross-origin or already gone */ } } -} - -function clearPendingConnect(): void { - try { sessionStorage.removeItem(CONNECT_PENDING_KEY) } catch { /* nothing to clear */ } + const popup = openDisownedPopup( + flow.url, uniquePopupName('gadgets-connect'), { kind: 'connect', nonce: flow.nonce }) + lastConnectPopup = popup + return popup } /** - * Listens for the ticket a connect popup delivers and redeems it on the user's session. Two - * transports are watched: a BroadcastChannel named `CONNECT_HANDOFF_MESSAGE_TYPE` (a disowned popup - * on our own origin; the browser scopes the channel to that origin) and `message` events from the - * gatekeeper origin (a popup that kept its opener, as under the dev server). Only well-formed - * envelopes are considered; anything else is ignored silently. A popup that posted is closed once - * the Workshop has accepted the ticket. A broadcast has no source, so that page repeats its - * envelope (a tab whose session is mid-reconnect would miss a one-shot) until this tab answers with - * a `CONNECT_HANDOFF_ACK_MESSAGE_TYPE` envelope once the redemption succeeded, then closes itself. - * - * Security rests on the ticket being scoped server-side to the user who started the flow, not on - * which window sent it. The `sessionStorage` marker `openConnectWindow` sets only decides *which of - * that user's tabs* redeems a broadcast: the one that opened the popup, surviving a reload, since - * the storage is per-tab and reload-stable; its siblings stay silent instead of racing it and - * toasting "expired". The marker is spent only by a successful redemption, so a sibling's or a - * sign-in ticket heard first (which the server rejects) does not cost this tab its own, and it ages - * out after the connect-nonce lifetime so an abandoned popup's marker stops racing siblings. A - * connect whose tab was closed expires and is revoked like an abandoned one. A phished handoff page - * opened directly in the victim's own browser broadcasts to tabs none of which holds a marker, so - * nothing even reaches the server; a `message` event needs no marker, its source being the popup - * this tab itself holds. - * - * Pass `null` to listen for nothing: a ticket must be redeemed exactly once, so only one listener may - * be live per window (see `ConnectHandoffListener` and the blueprint page). + * The `PopupHandoff` the Workshop tab wrote into this document's sessionStorage, removed as it is + * read (single-use on the client as well as the server), or null when there is none, it is + * malformed, or storage is unavailable. */ -export function useConnectHandoffListener( - authenticatedApi: RpcStub | null, - onError: (message: string) => void, -): void { - useEffect(() => { - if (!authenticatedApi) return - const channel = 'BroadcastChannel' in globalThis - ? new BroadcastChannel(CONNECT_HANDOFF_MESSAGE_TYPE) - : null - // `source` is the popup that posted the ticket, or null for a broadcast, whose page is told to - // close by the ack instead. - const redeem = (ticket: string, source: Window | null) => { - authenticatedApi.completeConnectHandoff(ticket).then( - () => { - if (source) { - source.close?.() - return - } - clearPendingConnect() - // oxlint-disable-next-line unicorn/require-post-message-target-origin -- a BroadcastChannel has no targetOrigin. - channel?.postMessage({ type: CONNECT_HANDOFF_ACK_MESSAGE_TYPE, ticket }) - }, - (err: unknown) => { onError(err instanceof Error ? err.message : String(err)) }, - ) - } - const onMessage = (event: MessageEvent) => { - const ticket = connectHandoffTicket(event) - if (ticket !== null) redeem(ticket, event.source as Window | null) - } - window.addEventListener('message', onMessage) - // Tickets already tried on this session: the page repeats its broadcast until acked, and a - // sibling tab's page may repeat too, so a ticket is redeemed (and a failure toasted) once. - const attempted = new Set() - channel?.addEventListener('message', (event: MessageEvent) => { - const ticket = parseHandoffEnvelope(event.data) - if (ticket === null || attempted.has(ticket) || !hasPendingConnect()) return - attempted.add(ticket) - redeem(ticket, null) - }) - return () => { - window.removeEventListener('message', onMessage) - channel?.close() - } - }, [authenticatedApi, onError]) +export function readPopupHandoff(): PopupHandoff | null { + let raw: string | null + try { + raw = sessionStorage.getItem(HANDOFF_KEY) + sessionStorage.removeItem(HANDOFF_KEY) + } catch { + return null + } + if (raw === null) return null + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) return null + const { kind, nonce } = parsed as { kind?: unknown; nonce?: unknown } + if (kind !== 'connect' && kind !== 'login') return null + if (typeof nonce !== 'string' || !HEX_256_PATTERN.test(nonce)) return null + return { kind, nonce } } diff --git a/packages/workshop-frontend/src/rootRoute.test.tsx b/packages/workshop-frontend/src/rootRoute.test.tsx new file mode 100644 index 0000000000..458e411d9f --- /dev/null +++ b/packages/workshop-frontend/src/rootRoute.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, type ComponentType } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub } from 'capnweb' +import type { PublicApi } from '@gadgets/workshop-shared/api' + +const testState = vi.hoisted(() => ({ + pathname: '/', + isLoading: false, + // A signed-in tab's stub: the shell would call this first, so a popup routed into the shell by + // mistake shows up as a call here. + authenticatedApi: null as { isOnboardingCompleted: () => Promise } | null, +})) + +// The root decides standalone-vs-shell from the pathname and the auth state alone; both are faked +// here, and the routed page is a marker so no real screen renders. +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useRouterState: ({ select }: { select: (s: { location: { pathname: string } }) => unknown }) => + select({ location: { pathname: testState.pathname } }), + Outlet: () =>
routed page
, +})) + +vi.mock('./useAuth', () => ({ + CF_ACCESS_MODE: false, + useAuth: () => ({ + isAuthenticated: testState.authenticatedApi !== null, + authenticatedApi: testState.authenticatedApi, + isLoading: testState.isLoading, + error: null, + login: vi.fn<(token: string) => void>(), + logout: vi.fn<() => void>(), + }), +})) + +vi.mock('./components/Header', () => ({ default: () =>
Header
})) +vi.mock('./LoginPage', () => ({ default: () =>
Login
})) + +import { Route } from './routes/__root' +import { RpcContext } from './RpcContext' +import { HANDOFF_PATH } from './connectHandoff' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const RootComponent = Route.options.component as ComponentType + +describe('root route standalone rendering', () => { + let root: Root | undefined + let container: HTMLDivElement | undefined + const stub = {} as RpcStub + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + testState.pathname = '/' + testState.isLoading = false + testState.authenticatedApi = null + }) + + async function renderAt(pathname: string, isLoading = false) { + testState.pathname = pathname + testState.isLoading = isLoading + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root!.render( + + + , + ) + }) + return container + } + + it('renders the handoff page standalone, without waiting on auth', async () => { + const page = await renderAt(HANDOFF_PATH, true) + + expect(page.querySelector('[data-testid="outlet"]')).not.toBeNull() + expect(page.querySelector('[data-testid="header"]')).toBeNull() + expect(page.querySelector('[data-testid="login"]')).toBeNull() + expect(page.textContent).not.toContain('Loading') + }) + + it('renders the handoff page standalone for a signed-in popup, never the app shell', async () => { + // The common case: a connect popup shares the tab's authToken, so it is authenticated. It must + // still bypass the shell (onboarding gate, account modal, sidebar) and render the page itself. + const isOnboardingCompleted = vi.fn<() => Promise>().mockResolvedValue(false) + testState.authenticatedApi = { isOnboardingCompleted } + const page = await renderAt(HANDOFF_PATH) + + expect(page.querySelector('[data-testid="outlet"]')).not.toBeNull() + expect(page.querySelector('[data-testid="header"]')).toBeNull() + expect(page.querySelector('[data-testid="login"]')).toBeNull() + expect(isOnboardingCompleted).not.toHaveBeenCalled() + }) + + it('renders the handoff page headerless for a signed-out popup too', async () => { + const page = await renderAt(HANDOFF_PATH) + + expect(page.querySelector('[data-testid="outlet"]')).not.toBeNull() + expect(page.querySelector('[data-testid="header"]')).toBeNull() + expect(page.querySelector('[data-testid="login"]')).toBeNull() + }) + + it('still renders signup headerless', async () => { + const page = await renderAt('/signup') + + expect(page.querySelector('[data-testid="outlet"]')).not.toBeNull() + expect(page.querySelector('[data-testid="header"]')).toBeNull() + expect(page.querySelector('[data-testid="login"]')).toBeNull() + }) + + it('shows the login page for a signed-out visitor elsewhere', async () => { + const page = await renderAt('/') + + expect(page.querySelector('[data-testid="login"]')).not.toBeNull() + expect(page.querySelector('[data-testid="outlet"]')).toBeNull() + }) +}) diff --git a/packages/workshop-frontend/src/routeTree.gen.ts b/packages/workshop-frontend/src/routeTree.gen.ts index e81a43f809..49ca6789ab 100644 --- a/packages/workshop-frontend/src/routeTree.gen.ts +++ b/packages/workshop-frontend/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as ProvidersRouteImport } from './routes/providers' import { Route as SignupRouteImport } from './routes/signup' import { Route as WorkspacesRouteImport } from './routes/workspaces' import { Route as BlueprintIdRouteImport } from './routes/blueprint.$id' +import { Route as ConnectHandoffRouteImport } from './routes/connect.handoff' import { Route as GadgetIdRouteImport } from './routes/gadget.$id' import { Route as GatekeepersAppIdRouteImport } from './routes/gatekeepers_.$appId' import { Route as WorkspaceIdRouteImport } from './routes/workspace.$id' @@ -85,6 +86,11 @@ const BlueprintIdRoute = BlueprintIdRouteImport.update({ path: '/blueprint/$id', getParentRoute: () => rootRouteImport, } as any) +const ConnectHandoffRoute = ConnectHandoffRouteImport.update({ + id: '/connect/handoff', + path: '/connect/handoff', + getParentRoute: () => rootRouteImport, +} as any) const GadgetIdRoute = GadgetIdRouteImport.update({ id: '/gadget/$id', path: '/gadget/$id', @@ -114,6 +120,7 @@ export interface FileRoutesByFullPath { '/signup': typeof SignupRoute '/workspaces': typeof WorkspacesRoute '/blueprint/$id': typeof BlueprintIdRoute + '/connect/handoff': typeof ConnectHandoffRoute '/gadget/$id': typeof GadgetIdRoute '/gatekeepers/$appId': typeof GatekeepersAppIdRoute '/workspace/$id': typeof WorkspaceIdRoute @@ -131,6 +138,7 @@ export interface FileRoutesByTo { '/signup': typeof SignupRoute '/workspaces': typeof WorkspacesRoute '/blueprint/$id': typeof BlueprintIdRoute + '/connect/handoff': typeof ConnectHandoffRoute '/gadget/$id': typeof GadgetIdRoute '/gatekeepers/$appId': typeof GatekeepersAppIdRoute '/workspace/$id': typeof WorkspaceIdRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/signup': typeof SignupRoute '/workspaces': typeof WorkspacesRoute '/blueprint/$id': typeof BlueprintIdRoute + '/connect/handoff': typeof ConnectHandoffRoute '/gadget/$id': typeof GadgetIdRoute '/gatekeepers_/$appId': typeof GatekeepersAppIdRoute '/workspace/$id': typeof WorkspaceIdRoute @@ -168,6 +177,7 @@ export interface FileRouteTypes { | '/signup' | '/workspaces' | '/blueprint/$id' + | '/connect/handoff' | '/gadget/$id' | '/gatekeepers/$appId' | '/workspace/$id' @@ -185,6 +195,7 @@ export interface FileRouteTypes { | '/signup' | '/workspaces' | '/blueprint/$id' + | '/connect/handoff' | '/gadget/$id' | '/gatekeepers/$appId' | '/workspace/$id' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/signup' | '/workspaces' | '/blueprint/$id' + | '/connect/handoff' | '/gadget/$id' | '/gatekeepers_/$appId' | '/workspace/$id' @@ -220,6 +232,7 @@ export interface RootRouteChildren { SignupRoute: typeof SignupRoute WorkspacesRoute: typeof WorkspacesRoute BlueprintIdRoute: typeof BlueprintIdRoute + ConnectHandoffRoute: typeof ConnectHandoffRoute GadgetIdRoute: typeof GadgetIdRoute GatekeepersAppIdRoute: typeof GatekeepersAppIdRoute WorkspaceIdRoute: typeof WorkspaceIdRoute @@ -311,6 +324,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlueprintIdRouteImport parentRoute: typeof rootRouteImport } + '/connect/handoff': { + id: '/connect/handoff' + path: '/connect/handoff' + fullPath: '/connect/handoff' + preLoaderRoute: typeof ConnectHandoffRouteImport + parentRoute: typeof rootRouteImport + } '/gadget/$id': { id: '/gadget/$id' path: '/gadget/$id' @@ -348,6 +368,7 @@ const rootRouteChildren: RootRouteChildren = { SignupRoute: SignupRoute, WorkspacesRoute: WorkspacesRoute, BlueprintIdRoute: BlueprintIdRoute, + ConnectHandoffRoute: ConnectHandoffRoute, GadgetIdRoute: GadgetIdRoute, GatekeepersAppIdRoute: GatekeepersAppIdRoute, WorkspaceIdRoute: WorkspaceIdRoute, diff --git a/packages/workshop-frontend/src/routes/__root.tsx b/packages/workshop-frontend/src/routes/__root.tsx index 5b0e477c5f..9d59015f18 100644 --- a/packages/workshop-frontend/src/routes/__root.tsx +++ b/packages/workshop-frontend/src/routes/__root.tsx @@ -7,7 +7,7 @@ import { AuthenticatedApi } from '@gadgets/workshop-shared/api' import { useRpcStub, useConnectionLost } from '../RpcContext' import { useAuth, CF_ACCESS_MODE } from '../useAuth' import { AuthProvider } from '../AuthContext' -import { ConnectHandoffListener } from '../ConnectHandoffListener' +import { HANDOFF_PATH } from '../connectHandoff' import { FeatureFlagsProvider } from '../FeatureFlagsContext' import Header from '../components/Header' import AppShell from '../components/AppShell/AppShell' @@ -28,11 +28,14 @@ function RootComponent() { // Routes that don't require auth (public routes) const isSignup = pathname === '/signup' const isBlueprint = pathname.startsWith('/blueprint/') + // The connect / sign-in handoff popup needs no shell and must not wait on auth: a sign-in popup + // has no session, and ConnectHandoffPage runs its own useAuth for connects. + const isHandoff = pathname === HANDOFF_PATH - // A standalone (no app shell) render is used only for signed-out visitors of public routes. - // Signed-in users get the full app chrome so public pages (esp. the blueprint detail) feel - // native — sidebar and all — instead of floating on a bare page. - const standalone = isSignup || (isBlueprint && !isAuthenticated) + // A standalone (no app shell) render is used for the handoff popup and for signed-out visitors + // of public routes. Signed-in users get the full app chrome so public pages (esp. the blueprint + // detail) feel native — sidebar and all — instead of floating on a bare page. + const standalone = isSignup || isHandoff || (isBlueprint && !isAuthenticated) // The workspace editor renders fullscreen (no app chrome). /gadget/ is the legacy URL, kept // here so the chrome doesn't flash in during the redirect to /workspace/. @@ -87,7 +90,7 @@ function RootComponent() { // Signed-out visitors of public routes render without the auth wrapper / app shell. if (standalone) { - const showHeader = !isSignup + const showHeader = !isSignup && !isHandoff return ( @@ -111,7 +114,6 @@ function RootComponent() { - [...new Set([...prev, ...resourceUrlPatterns])]) try { - const result = await authenticatedApi.ensureAccountResources( + const flow = await authenticatedApi.ensureAccountResources( modalTarget.accountId, resourceUrlPatterns, ) - if (result.url) { - openConnectWindow(result.url) - } - // On success the new grant arrives via subscribeConnectedAccounts(); the toggle reflects it - // once `grantedResourceUrlPatterns` updates. + if (flow) openConnectWindow(flow) + // The popup redeems the ticket itself; the new grant arrives via subscribeConnectedAccounts(), + // and the toggle reflects it once `grantedResourceUrlPatterns` updates. } catch (err) { console.error('Failed to expand account access:', err) toasts.add({ title: 'Failed to request additional access', variant: 'error' }) @@ -634,8 +631,7 @@ function ConnectorsPage() { const handleReconnect = async (accountId: number) => { setReconnectingAccountId(accountId) try { - const { url } = await authenticatedApi.reconnectAccount(accountId) - openConnectWindow(url) + openConnectWindow(await authenticatedApi.reconnectAccount(accountId)) } catch (err) { console.error('Failed to reconnect account:', err) toasts.add({ title: 'Failed to reconnect account', variant: 'error' }) diff --git a/packages/workshop-shared/src/api.ts b/packages/workshop-shared/src/api.ts index 22780cf063..41d12ad598 100644 --- a/packages/workshop-shared/src/api.ts +++ b/packages/workshop-shared/src/api.ts @@ -32,23 +32,36 @@ export const SERVICE_SALT = new Uint8Array([ 0xd9, 0x4e, 0x54, 0x1d, 0x29, 0xc1, 0x03, 0x74, 0x73, 0x7e, 0xb3, 0xe3, 0x34, 0x6d, 0x8f, 0x21 ]); +/** + * How a connect, reconnect, ensure-resources or sign-in flow starts, as returned by + * `AuthenticatedApi.connectAccount()` and its siblings. `url` is the gatekeeper's flow URL, which + * the Workshop opens as a disowned popup. `nonce` is a 64-lowercase-hex secret minted for this + * flow, which the Workshop writes into that popup's own sessionStorage before navigating it and + * nowhere else: sessionStorage is per top-level browsing context and per origin, so it survives the + * trip through the gatekeeper and the provider and is readable again once the popup is back on the + * Workshop's origin. When the flow finishes, the popup lands on the Workshop's /connect/handoff + * page, which presents the ticket from its URL fragment together with the nonce + * (`AuthenticatedApi.completeConnectHandoff()` / `PublicApi.confirmLogin()`). A handoff page opened + * any other way holds no nonce and redeems nothing. The nonce is single-use and dies with the flow: + * a connect's after CONNECT_FLOW_LIFETIME_MS (30 minutes, server-side), a sign-in's with its + * `PendingLogin` attempt. + */ +export type ConnectFlowStart = { url: string; nonce: string }; + /** * A pending gatekeeper sign-in attempt, returned by `PublicApi.startGatekeeperLogin()`. Holding this * stub is the capability to receive the resulting session token; dispose it to abandon the attempt. */ export interface LoginAttempt extends RpcTarget { /** - * Redeem the handoff ticket the sign-in popup posted to this window (the `ticket` of a - * `CONNECT_HANDOFF_MESSAGE_TYPE` message, exactly as for `AuthenticatedApi.completeConnectHandoff`) - * for a session token (to store and pass to `authenticate()`, same format as `login()`). Resolves - * null when the ticket belongs to a different attempt (a broadcast can carry another window's), - * including one that arrives before this attempt has finished; in either case the attempt is - * untouched and the caller keeps listening. Rejects if the gatekeeper - * reported a failure or the attempt has expired or was already claimed. Holding this stub alone - * never yields a token: the sign-in URL is a bearer capability, and only the browser that finished - * it receives the ticket. - */ - claim(ticket: string): Promise; + * The session token (same format as `login()`; store it and pass it to `authenticate()`) once the + * sign-in popup has confirmed the attempt's ticket via `PublicApi.confirmLogin()`; null until + * then, so the caller polls. Throws with a user-facing message once the attempt has expired, the + * gatekeeper reported a failure, or the token was already received. Holding this stub alone never + * yields a token: the sign-in URL is a bearer capability, and only the popup this browser opened + * holds the nonce that confirms it. + */ + receive(): Promise; } /** Public API exposed to the internet. */ @@ -64,15 +77,27 @@ export interface PublicApi extends RpcTarget { /** * Begin a sign-in via an authentication gatekeeper (e.g. "google", "github", "cloudflare"). - * Returns a `url` the client opens as a popup with the opener retained (unlike - * `AuthenticatedApi.connectAccount`, whose popup is disowned) and an `attempt` stub whose `claim()` - * exchanges the ticket the popup posts back for the session token. The vendor must be - * auth-capable and allowlisted (see ServerConfig.authVendors); throws otherwise. + * Returns the `url` the client opens as a disowned popup, the `nonce` it writes into that popup's + * sessionStorage before navigating it (see `ConnectFlowStart`), and an `attempt` stub the client + * polls with `receive()` for the session token. When the flow finishes, the popup lands on the + * Workshop's own /connect/handoff page, which calls `confirmLogin(ticket, nonce)`. The vendor must + * be auth-capable and allowlisted (see ServerConfig.authVendors); throws otherwise. * * Dispose `attempt` to abandon the sign-in (e.g. the user closed the popup). Nothing is cancelled - * server-side: the browser just stops listening, and an unclaimed token expires on its own. + * server-side: the browser just stops polling, and an unreceived token expires on its own. */ - startGatekeeperLogin(vendorId: string): Promise<{ url: string; attempt: RpcStub }>; + startGatekeeperLogin(vendorId: string): Promise<{ url: string; nonce: string; attempt: RpcStub }>; + + /** + * Confirm a finished sign-in flow. Called by the /connect/handoff page in the sign-in popup, which + * has no session: `ticket` is the handoff ticket from the page's URL fragment and `nonce` the one + * `startGatekeeperLogin()` returned for the same flow, read from the popup's own sessionStorage. + * Marks the attempt's delivered result as confirmed, so that `LoginAttempt.receive()` releases the + * token to whoever holds the attempt stub; the popup itself never sees a token. Throws with a + * user-facing message when the attempt is unknown, expired, or failed, or the ticket is not the + * attempt's. + */ + confirmLogin(ticket: string, nonce: string): Promise; /** Authenticates the user using an auth token (typically stored in localStorage). */ authenticate(token: string): Promise; @@ -533,12 +558,12 @@ export interface AuthenticatedApi extends RpcTarget { listGatekeeperVendors(filter?: GatekeeperVendorFilter): Promise; /** - * Connect this account to a specific account on a third-party service. Returns the URL which - * should be opened as a popup in the user's browser to complete the authorization; the Workshop - * disowns the popup before navigating it (see `openConnectWindow`), so the flow's final page - * delivers a handoff ticket over a same-origin `BroadcastChannel` (`CONNECT_HANDOFF_MESSAGE_TYPE`), - * which the client redeems with completeConnectHandoff(); only then is the account added to the - * list, which can be observed through subscribeConnectedAccounts(). + * Connect this account to a specific account on a third-party service. Returns the URL which the + * Workshop opens as a disowned popup to complete the authorization, plus the flow's nonce (see + * `ConnectFlowStart`). When the flow finishes, the popup lands on the Workshop's own + * /connect/handoff page, which redeems the handoff with completeConnectHandoff() over its own + * session; only then is the account added to the list, which can be observed through + * subscribeConnectedAccounts(). * * `resourceUrlPatterns`, if given, limits the connection to the authorization needed for those * grantable resource types (those with `grantable`; see `SupportedResource`). If omitted, @@ -547,26 +572,29 @@ export interface AuthenticatedApi extends RpcTarget { * caller connects an account for a non-resource purpose (e.g. billing) without asking the user to * grant data access it will never use. */ - connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise<{url: string}>; + connectAccount(vendorId: string, resourceUrlPatterns?: string[]): Promise; /** - * Redeem the handoff ticket a connect popup delivered to this window (the `ticket` of a - * `CONNECT_HANDOFF_MESSAGE_TYPE` message, over the broadcast channel or, where the popup kept - * its opener, by `postMessage`). Activates the pending connect / reconnect / - * ensure-resources grant if it was started by this user, after which the account (or its - * restored credentials) appears via subscribeConnectedAccounts(). Throws if the ticket is - * unknown to this user, already redeemed, or expired. + * Redeem a finished connect flow's handoff. Called by the Workshop's own /connect/handoff page + * running in the popup, over the popup's session, which is the initiating user's (the SPA + * authenticates as any Workshop tab does: from the shared localStorage token, or from the + * Cloudflare Access identity in an Access deployment). `ticket` is the handoff ticket from the + * page's URL fragment; `nonce` must be the one connectAccount() / reconnectAccount() / + * ensureAccountResources() returned for the flow that produced the ticket, read from the popup's + * own sessionStorage. Both are single-use. Activates the pending connect / reconnect / + * ensure-resources grant, after which the account (or its restored credentials) appears via + * subscribeConnectedAccounts(). Throws with a user-facing message if the ticket or nonce is + * unknown to this user, already used, or expired, or they belong to different flows. */ - completeConnectHandoff(ticket: string): Promise; + completeConnectHandoff(ticket: string, nonce: string): Promise; /** * Ensure the authorization for the listed grantable resource types (by `urlPattern`) is granted - * on a connected account, expanding if needed. Returns a URL to open as a popup (disowned, as for - * connectAccount()) to authorize them, or no url if nothing was needed. Completion is - * confirmed via completeConnectHandoff(); the updated grant is then observable via - * subscribeConnectedAccounts(). + * on a connected account, expanding if needed. Returns a flow to open as a disowned popup (as for + * connectAccount()) to authorize them, or null if nothing was needed. Completion is redeemed via + * completeConnectHandoff(); the updated grant is then observable via subscribeConnectedAccounts(). */ - ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise<{url?: string}>; + ensureAccountResources(accountId: number, resourceUrlPatterns: string[]): Promise; /** * List the auto-provisioning ("ambient") gatekeepers the user can opt into right now: those set to @@ -694,11 +722,12 @@ export interface AuthenticatedApi extends RpcTarget { /** * Re-authenticate a connected account whose credentials have expired (or may be about to - * expire). Returns the URL to open as a popup (disowned, as for connectAccount()). Once - * the OAuth flow completes and the client redeems the handoff via completeConnectHandoff(), the - * account is updated and subscribers are notified with credentialsValid: true. + * expire). Returns a flow to open as a disowned popup (as for connectAccount()). Once the OAuth + * flow completes and the popup's /connect/handoff page redeems the handoff via + * completeConnectHandoff(), the account is updated and subscribers are notified with + * credentialsValid: true. */ - reconnectAccount(accountId: number): Promise<{url: string}>; + reconnectAccount(accountId: number): Promise; // --- Gatekeeper management apps --- diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index b2f1883ace..7d2b3819f0 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -443,37 +443,19 @@ export type GatekeeperConnectOptions = { resourceUrlPatterns?: string[]; }; -/** - * The `type` field of the envelope `{type, ticket}` that a finished connect flow's browser tab - * delivers to the Workshop, and the name of the same-origin `BroadcastChannel` it delivers over when - * it has no opener to `postMessage` to. Versioned so the listener can ignore envelopes from an older - * or newer page. - */ -export const CONNECT_HANDOFF_MESSAGE_TYPE = "gadgets.connect-handoff.v1"; - -/** - * The `type` field of the envelope `{type, ticket}` the Workshop posts back on the same - * `BroadcastChannel` once it has redeemed a broadcast ticket, so the completion page stops repeating - * the handoff and closes. Never sent to an opener: the Workshop closes that popup itself. - */ -export const CONNECT_HANDOFF_ACK_MESSAGE_TYPE = "gadgets.connect-handoff-ack.v1"; - /** * What the browser tab that finished a connect flow must deliver to the Workshop, as returned by * `GatekeeperConnectCallback.complete()` / `reconnectComplete()`. * - * `ticket` is a single-use secret the Workshop redeems over the initiating user's authenticated RPC - * session (`AuthenticatedApi.completeConnectHandoff`); the staged grant is activated only when it - * arrives from that user. `targetOrigin` is the Workshop's origin. The completion page delivers the - * envelope one of two ways: `postMessage` to its opener with `targetOrigin` passed verbatim, so a - * browser drops the ticket if the opener is anyone else (sign-in popups keep their opener); or, for - * a connect popup the Workshop disowned before navigating it, a `BroadcastChannel` that the page - * opens only when it is itself on `targetOrigin` — the browser scopes the channel to that origin. - * Over the channel the page repeats the envelope until a Workshop tab acknowledges it - * (`CONNECT_HANDOFF_ACK_MESSAGE_TYPE`), since a tab whose session is mid-reconnect would miss a - * one-shot broadcast; the ticket is single-use server-side, so the repeats are harmless. - * Opaque to gatekeepers: they only render it into the completion page (see `connectHandoffPageHtml` - * in gatekeeper-kit). + * `ticket` is a single-use secret redeemed over the initiating user's authenticated RPC session + * (`AuthenticatedApi.completeConnectHandoff`, or confirmed via `PublicApi.confirmLogin` for + * sign-in); the staged grant is activated only then. `targetOrigin` is the Workshop's origin. The + * completion page navigates the popup to `/connect/handoff#`, and that + * Workshop page redeems the ticket over the popup's own session together with a per-flow nonce that + * only this popup holds (the Workshop wrote it into the popup's sessionStorage before navigating + * it). The fragment never reaches a server or a Referer, and `location.replace()` leaves no + * history entry. Opaque to gatekeepers: they only render it into the completion page (see + * `connectHandoffPageHtml` in gatekeeper-kit). */ export type ConnectHandoff = { targetOrigin: string; From d0dfc098a665618fe11d03304ca776c834aea19e Mon Sep 17 00:00:00 2001 From: Maximo Guk <62088388+Maximo-Guk@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:17:29 -0500 Subject: [PATCH 08/15] Rename prohibitAllSharing -> containsRestrictedData (#381) * typed-storage: Let a schema declare the storage key it lives under. A schema property name is also the KV key it maps to, so renaming a property in code is a storage migration. Give a singleton slot somewhere to say otherwise: `singleton(defaultValue, {storageKey})` declares the key on disk explicitly, and a bare default value stays the shorthand for the common case and behaves exactly as before. Collections get the same option as `storageName`, which prefixes the records and every index alike. This is the schema-level version of what would otherwise be a special case at each call site, and it keeps the old name on disk with no migration. * Refactor: Rename prohibitAllSharing -> containsRestrictedData. The flag's real meaning is "this observation contains restricted data". What the platform does about that is policy, which shouldn't be baked into the name -- the next commits replace the all-or-nothing lockdown with per-collaborator observer verification. ObservationDescription.prohibitAllSharing and GadgetMetadata.sharingProhibited both become containsRestrictedData. No alias: this is a hard rename, so the gatekeeper call sites move in the same commit. The overseer's durable singleton is renamed too, and declares its old name as its `storageKey` so nothing on disk moves. Without that, every workspace that has already observed restricted data would silently unlatch. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .agents/skills/write-gatekeeper/SKILL.md | 2 +- docs/observers.md | 14 +- docs/sharing.md | 2 +- .../__tests__/drive-session.test.ts | 2 +- packages/gatekeeper-google/src/google.ts | 16 +- .../__tests__/observers.test.ts | 6 +- packages/gatekeeper-mcp/README.md | 11 +- .../typed-storage/__tests__/index.test.ts | 243 +++++++++++++++++- packages/typed-storage/src/index.ts | 106 +++++++- .../workshop-backend/__tests__/fixtures.ts | 2 +- packages/workshop-backend/src/overseer.ts | 46 ++-- packages/workshop-backend/src/sharing.ts | 6 +- packages/workshop-frontend/src/ShareModal.tsx | 26 +- packages/workshop-shared/src/api.ts | 2 +- packages/workshop-shared/src/gatekeeper.ts | 2 +- plans/gatekeeper-kit.md | 4 +- plans/multi-gadget.md | 4 +- 17 files changed, 408 insertions(+), 86 deletions(-) diff --git a/.agents/skills/write-gatekeeper/SKILL.md b/.agents/skills/write-gatekeeper/SKILL.md index 7416bd12db..1e470f73c0 100644 --- a/.agents/skills/write-gatekeeper/SKILL.md +++ b/.agents/skills/write-gatekeeper/SKILL.md @@ -243,7 +243,7 @@ async getVerifier(): Promise> { Strategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C). -- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). +- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: the workspace then refuses sensitive observations while any unverified collaborator has access (with strategy A that is every collaborator) and latches into a restricted mode that blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers. - **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings. - **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings. - **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services). diff --git a/docs/observers.md b/docs/observers.md index 14de56735e..0dc8db05e0 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -30,8 +30,8 @@ Gadgets enforce a core security invariant (see `overview.md` §"Security Model") > able to read that information will also be prohibited from interacting with the Gadget, > to prevent data leaks. -Today the only mechanism enforcing this is the blunt **`prohibitAllSharing`** flag -(`packages/workshop-shared/src/gatekeeper.ts`, `ObservationDescription.prohibitAllSharing`). +Today the only mechanism enforcing this is the blunt **`containsRestrictedData`** flag +(`packages/workshop-shared/src/gatekeeper.ts`, `ObservationDescription.containsRestrictedData`). When a gatekeeper marks an observation as maximally sensitive, the Gadget can no longer be shared with *anyone*, and it drops into "lockdown" (no further actions, no web fetches). This is a deliberate stopgap — it cannot express "this data may be shared, but only with people who @@ -99,7 +99,7 @@ This feature replaces that all-or-nothing posture with a per-user, gatekeeper-me | Session restart when verification scope widens | `overseer.ts` (`#restartIfSessionsAffected`, `joinSession`, `scheduleAccessRestart`) | | Server `openGadget` path | `packages/workshop-backend/src/server.ts:206` | | Role resolution / permission graph | `packages/workshop-backend/src/sharing.ts` (`getEffectiveRole`, `computeEffectiveRoles`, `hasAnyShares`) | -| `prohibitAllSharing` enforcement | `overseer.ts:1171` (`authorizeObservation`), `:1207` (web fetch), `:1258` (`submitAction`) | +| `containsRestrictedData` enforcement | `overseer.ts:1171` (`authorizeObservation`), `:1207` (web fetch), `:1258` (`submitAction`) | | Observation recording | `overseer.ts:1169` `authorizeObservation()`; `ApprovalQueueImpl` `overseer.ts:4856` | | Gatekeeper storage record | `overseer.ts:110` `GatekeeperRecord` (has `creationSpec.vendorId`) | | `GatekeeperCreationSpec` | `packages/workshop-shared/src/api.ts:1345` | @@ -233,7 +233,7 @@ type ObserverAccountChoice = { ### Step 3 — Overseer: observer configuration & re-verification at `open()` Hook into `open()` in the non-owner branch, after `effectiveRole` is confirmed and before -constructing the client interface. Keep the existing `prohibitAllSharing` short-circuit ahead of +constructing the client interface. Keep the existing `containsRestrictedData` short-circuit ahead of this -- lockdown still wins. The `NeedsConnections` signal is produced only *after* a valid role is confirmed, so it never reveals a workspace's gatekeeper or resource metadata to an unauthorized user. @@ -648,7 +648,7 @@ already in the JSDoc in `gatekeeper.ts`; add anything missing there rather than operational failure (vendor outage, expired credential) is treated the same way — the overseer cannot tell it from a settled denial — and the collaborator gets back in as soon as a repaired open re-verifies them. -4. **`prohibitAllSharing` interaction** — unchanged and still authoritative: if set, no non-owner +4. **`containsRestrictedData` interaction** — unchanged and still authoritative: if set, no non-owner can open at all (`overseer.ts:2770`). Observer checks only matter when sharing is allowed. 5. **Owner adds a new binding after sharing** — existing observers see an incremental modal for just the new binding on their next open, and may be denied if they lack access to the new @@ -733,8 +733,8 @@ gatekeeper package — a single package (e.g. `gatekeeper-google`) may use sever its resource types. - **A — Private-only.** Non-owner observers are refused: `addObserver()` unconditionally throws. - This is the replacement for today's reliance on `prohibitAllSharing` for these resources (the - `prohibitAllSharing` lockdown mechanism itself is unchanged and remains available separately). + This is the replacement for today's reliance on `containsRestrictedData` for these resources (the + `containsRestrictedData` lockdown mechanism itself is unchanged and remains available separately). `getVerifier()` must still exist (the overseer mints one on every open) but is never consulted. - **B — ACL check (single unit).** The resource is treated as one atomic unit. diff --git a/docs/sharing.md b/docs/sharing.md index 6a23c00311..a90d77b5e3 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -156,7 +156,7 @@ Authorization is only checked at `open()`, so a session that is *already* open i Two precautions surround the abort (`OverseerImpl.scheduleAccessRestart`): the severed edge is flushed with `ctx.storage.sync()` first (because `ctx.abort()` does not respect the output gate, a restart could otherwise come back with the change lost), and the abort is delayed ~100ms so the triggering RPC's response reaches the caller -- typically the owner, who is also connected -- before their own connection drops. The disconnect reaches the browser through the existing `notifyClosed` plumbing: when the Overseer DO aborts, the per-session `notifyClosed` stub is disposed without being called, which `AuthenticatedApiImpl` treats as a lost connection and reacts to by killing the browser WebSocket, forcing a reconnect. -Granting or raising access never strands anyone: a live session's capability is fixed at open, so a `use` collaborator promoted to `build` in the graph still holds `UseOverseerInterface` until they re-open, and nobody is newly excluded from anything. `prohibitAllSharing` cannot strand a session either: an observation that would set that flag is *blocked* (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict. +Granting or raising access never strands anyone: a live session's capability is fixed at open, so a `use` collaborator promoted to `build` in the graph still holds `UseOverseerInterface` until they re-open, and nobody is newly excluded from anything. `containsRestrictedData` cannot strand a session either: an observation that would set that flag is *blocked* (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict. The same abort serves a second purpose, though, and there the trigger is a *grant*: observer verification (see docs/observers.md) also runs only at `open()`, so widening the set of gatekeepers a collaborator must be verified against leaves their live session holding access they were never verified for. `OverseerImpl.#restartIfSessionsAffected` restarts the workspace whenever that happens -- a connection is added, one is bound into a gadget, or a merge promotes such a binding -- so every client re-opens and re-runs `ensureObserver` at the new scope. It is a no-op unless a collaborator session of the affected role is live -- severing sessions is all a restart does -- so a solo workspace, or one whose collaborators are all disconnected, is never disturbed. See docs/observers.md, "Restarting when verification scope widens", for the full trigger list and the reasoning about what deliberately does *not* trigger it. diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index a499495883..bb9ae07f9c 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -125,7 +125,7 @@ describe("Drive session scope", () => { description: expect.stringContaining('name starts with "missing"'), excludeObservers: ["excluded"], })]); - expect(authorizations[0]).not.toHaveProperty("prohibitAllSharing"); + expect(authorizations[0]).not.toHaveProperty("containsRestrictedData"); expect(authorizations[0].description).not.toContain("0"); expect(events).toEqual(["authorize"]); }); diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 4b44683ed6..16d7da88c6 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -3361,7 +3361,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { `Referenced tables: ${estimate.referencedTables.join(", ")}\n` + `Estimated bytes processed: ${estimate.bytesProcessed.toLocaleString()}\n` + `Maximum bytes billed: ${maxBytes.toLocaleString()}.`, - prohibitAllSharing: true, + containsRestrictedData: true, }); let result = await this.#api.query(billingProject, sql, { @@ -3393,7 +3393,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { description: `Estimated bytes processed: ${estimate.bytesProcessed.toLocaleString()}\n` + `Referenced tables: ${estimate.referencedTables.join(", ") || "(none)"}`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return estimate; @@ -3405,7 +3405,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { await this.#authorizeDatasets([], { title: "Get BigQuery project", description: `Returned the scoped project: \`${this.#scopedProjectId}\`.`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return result; } @@ -3426,7 +3426,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { await this.#authorizeDatasets([{ projectId: p, datasetId: this.#scopedDatasetId }], { title: `List datasets in ${p}`, description: `Returned scoped dataset \`${p}.${this.#scopedDatasetId}\` (1 dataset).`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return [dataset]; } @@ -3436,7 +3436,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { await this.#authorizeDatasets(result.map(ds => ({ projectId: p, datasetId: ds.datasetId })), { title: `List datasets in ${p}`, description: `Listed ${result.length} dataset(s) in \`${p}\`.`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return result; } @@ -3462,7 +3462,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { await this.#authorizeDatasets([{ projectId: p, datasetId: d }], { title: `List tables in ${p}.${d}`, description: `Returned scoped table \`${p}.${d}.${this.#scopedTableId}\` (1 table).`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return [table]; } @@ -3471,7 +3471,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { await this.#authorizeDatasets([{ projectId: p, datasetId: d }], { title: `List tables in ${p}.${d}`, description: `Listed ${result.length} table(s) in \`${p}.${d}\`.`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return result; } @@ -3508,7 +3508,7 @@ class BigQuerySessionImpl extends RpcTarget implements BigQuerySession { title: `Describe ${p}.${d}.${t}`, description: `Described table \`${p}.${d}.${t}\` (${result.schema.length} columns).`, - prohibitAllSharing: true, + containsRestrictedData: true, }); return result; } diff --git a/packages/gatekeeper-kit/__tests__/observers.test.ts b/packages/gatekeeper-kit/__tests__/observers.test.ts index 75541a345b..99bd0ab772 100644 --- a/packages/gatekeeper-kit/__tests__/observers.test.ts +++ b/packages/gatekeeper-kit/__tests__/observers.test.ts @@ -1056,7 +1056,7 @@ describe("ObservationGate", () => { expect(authorizeObservation).not.toHaveBeenCalled(); }); - it("leaves the caller's prohibitAllSharing alone, being a gadget-wide escalation", async () => { + it("leaves the caller's containsRestrictedData alone, being a gadget-wide escalation", async () => { const authorizeObservation = vi.fn(async () => {}); const strategy: ObserverStrategy = { aclChecks: "per-read", @@ -1067,11 +1067,11 @@ describe("ObservationGate", () => { }; await new ObservationGate(fakeAuthorizer(authorizeObservation), strategy) - .authorize({ ...read, prohibitAllSharing: true }, { kind: "collections", ids: ["p1"] }); + .authorize({ ...read, containsRestrictedData: true }, { kind: "collections", ids: ["p1"] }); expect(authorizeObservation).toHaveBeenCalledWith({ ...read, - prohibitAllSharing: true, + containsRestrictedData: true, excludeObservers: ["limited"], }); }); diff --git a/packages/gatekeeper-mcp/README.md b/packages/gatekeeper-mcp/README.md index 93317ca886..1e993073d9 100644 --- a/packages/gatekeeper-mcp/README.md +++ b/packages/gatekeeper-mcp/README.md @@ -177,8 +177,8 @@ rules. A Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses unconditionally. Being able to authenticate to a server is not evidence of being allowed to see what the *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still -work — the alternative, marking every observation `prohibitAllSharing`, would latch a lockdown that -blocks every action for the rest of the session. See +work — the alternative, marking every observation `containsRestrictedData`, would latch a +restricted mode that blocks every action for the rest of the session. See [`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts). To share the work rather than the binding, publish the Gadget as a blueprint and let each person @@ -206,9 +206,10 @@ connect their own server. compatibility flag in `wrangler.jsonc`, which makes workerd reject reserved IP ranges after resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is what keeps `MCP_ALLOW_INSECURE` usable locally. -- **Sharing UI reports late.** `GadgetMetadata.sharingProhibited` derives only from - `prohibitAllSharing`, so creating a share key appears to succeed and fails when the recipient - opens it. Fixing this needs a kernel change. +- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from + `ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and + fails when the recipient opens it (their observer verification is refused). Fixing this needs a + kernel change. ## Layout diff --git a/packages/typed-storage/__tests__/index.test.ts b/packages/typed-storage/__tests__/index.test.ts index 78d816dd36..0bd1ef2ea2 100644 --- a/packages/typed-storage/__tests__/index.test.ts +++ b/packages/typed-storage/__tests__/index.test.ts @@ -1,5 +1,7 @@ -import { expect, it, describe } from "vitest" -import { createTypedStorage, collection, UniqueIndex, NonUniqueIndex } from "../src/index.js"; +import { expect, expectTypeOf, it, describe } from "vitest" +import { createTypedStorage, collection, singleton, Singleton, SingletonSchema, UniqueIndex, + NonUniqueIndex } + from "../src/index.js"; import { DurableObjectListOptions, DurableObjectStorage } from "@cloudflare/workers-types/experimental"; // We mock out DurableObjectStorage becaues otherwise we'd have to run the tests inside a @@ -137,6 +139,243 @@ describe("singletons", () => { storage.counter.put(555); expect(subscriber.lastValue).toStrictEqual(321); }); + + it("uses the property name as the storage key by default", () => { + let mockStorage = makeMockStorage(); + let storage = createTypedStorage(mockStorage, { + singletons: { + counter: singleton(0), + } + }); + + storage.counter.put(123); + + // Declaring a singleton with no options must be byte-identical on disk to a bare default. + expect(mockStorage.kv.get("counter")).toStrictEqual(123); + }); + + it("reads and writes a legacy storage key", () => { + let mockStorage = makeMockStorage(); + + // Data written by an earlier version of the schema, when the property was called `oldName`. + mockStorage.kv.put("oldName", 42); + + let storage = createTypedStorage(mockStorage, { + singletons: { + newName: singleton(0, {storageKey: "oldName"}), + } + }); + + expect(storage.newName.get()).toStrictEqual(42); + + storage.newName.put(43); + + expect(storage.newName.get()).toStrictEqual(43); + expect(mockStorage.kv.get("oldName")).toStrictEqual(43); + expect(mockStorage.kv.get("newName")).toBeUndefined(); + }); + + it("falls back to the default when the legacy key was never written", () => { + let mockStorage = makeMockStorage(); + let storage = createTypedStorage(mockStorage, { + singletons: { + newName: singleton(false, {storageKey: "oldName"}), + } + }); + + expect(storage.newName.get()).toStrictEqual(false); + + storage.newName.put(true); + + expect(mockStorage.kv.get("oldName")).toStrictEqual(true); + }); + + it("notifies subscribers for a legacy storage key", () => { + let mockStorage = makeMockStorage(); + let storage = createTypedStorage(mockStorage, { + singletons: { + newName: singleton(0, {storageKey: "oldName"}), + } + }); + + let subscriber = { + lastValue: -1, + update(value: number) { + this.lastValue = value; + } + }; + storage.newName.subscribe(subscriber); + + storage.newName.put(7); + + expect(subscriber.lastValue).toStrictEqual(7); + expect(mockStorage.kv.get("oldName")).toStrictEqual(7); + }); + + it("accepts null and undefined defaults with options, like bare defaults do", () => { + let storage = createTypedStorage(makeMockStorage(), { + singletons: { + bareNull: null, + bareUndefined: undefined, + optNull: singleton(null, {storageKey: "legacyNull"}), + optUndefined: singleton(undefined, {storageKey: "legacyUndefined"}), + } + }); + + expectTypeOf(storage.optNull).toEqualTypeOf>(); + expectTypeOf(storage.optUndefined).toEqualTypeOf>(); + + expect(storage.bareNull.get()).toStrictEqual(null); + expect(storage.bareUndefined.get()).toStrictEqual(undefined); + expect(storage.optNull.get()).toStrictEqual(null); + expect(storage.optUndefined.get()).toStrictEqual(undefined); + + storage.optNull.put("x"); + expect(storage.optNull.get()).toStrictEqual("x"); + }); + + it("rejects two singletons resolving to the same storage key", () => { + // Two explicit keys. + expect(() => createTypedStorage(makeMockStorage(), { + singletons: { + a: singleton(0, {storageKey: "shared"}), + b: singleton(0, {storageKey: "shared"}), + } + })).toThrow('Two singletons resolve to the same storage key "shared".'); + + // An explicit key colliding with another slot's default (property-name) key, in either order. + expect(() => createTypedStorage(makeMockStorage(), { + singletons: { + a: singleton(0, {storageKey: "b"}), + b: 0, + } + })).toThrow('Two singletons resolve to the same storage key "b".'); + expect(() => createTypedStorage(makeMockStorage(), { + singletons: { + b: 0, + a: singleton(0, {storageKey: "b"}), + } + })).toThrow('Two singletons resolve to the same storage key "b".'); + }); + + it("rejects a storage key containing a namespace delimiter", () => { + // `users:alice` is exactly where collection `users` stores record `alice`; an exact-name + // check would never notice, so the delimiters themselves are refused. + expect(() => createTypedStorage(makeMockStorage(), { + collections: {users: collection()({primaryKey: "name"})}, + singletons: {alias: singleton(0, {storageKey: "users:alice"})}, + })).toThrow('Singleton storage key "users:alice" must not contain "." or ":"'); + expect(() => createTypedStorage(makeMockStorage(), { + singletons: {alias: singleton(0, {storageKey: "users.byUid"})}, + })).toThrow('Singleton storage key "users.byUid" must not contain "." or ":"'); + }); + + it("types a bare default shaped like a schema as the object, not its defaultValue", () => { + // `SingletonSchema` is nominal: an object literal with the same public fields is a bare + // default at runtime, and must be one at the type level too, or `get()` would be typed as + // returning `number` while actually returning the object. + let lookalike = {defaultValue: 1, options: {}}; + let storage = createTypedStorage(makeMockStorage(), { + singletons: { + slot: lookalike, + } + }); + + expectTypeOf(storage.slot).toEqualTypeOf>(); + expectTypeOf(storage.slot).not.toEqualTypeOf>(); + expect(storage.slot.get()).toStrictEqual(lookalike); + }); + + it("types a union of schema and bare default distributively", () => { + // A schema entry typed as a union unwraps each member separately, rather than falling + // through to `Singleton | string>`. + let either: SingletonSchema | string = Math.random() < 2 ? singleton(0) : "s"; + let storage = createTypedStorage(makeMockStorage(), { + singletons: { + slot: either, + } + }); + + expectTypeOf(storage.slot).toEqualTypeOf>(); + expect(storage.slot.get()).toStrictEqual(0); + }); +}); + +describe("collections with a legacy storage name", () => { + it("stores records and indexes under the legacy prefix", () => { + let mockStorage = makeMockStorage(); + let storage = createTypedStorage(mockStorage, { + collections: { + people: collection()({ + storageName: "users", + primaryKey: "name", + uniqueIndexes: { + byUid: (user: User) => user.uid + }, + nonUniqueIndexes: { + byLevel: (user: User) => user.level + } + }) + } + }); + + storage.people.put(ALICE); + + expect(storage.people.get("alice")).toStrictEqual(ALICE); + expect(storage.people.byUid.get(45)).toStrictEqual(ALICE); + expect([...storage.people.byLevel.get(8)]).toStrictEqual([ALICE]); + + // Every key -- the record and both indexes -- lives under the legacy name, so a collection + // renamed in code reads data written before the rename. + let keys = [...mockStorage.kv.list({})].map(([key]) => key); + expect(keys.some(key => key.startsWith("users:"))).toStrictEqual(true); + expect(keys.some(key => key.startsWith("users.byUid:"))).toStrictEqual(true); + expect(keys.some(key => key.startsWith("users.byLevel:"))).toStrictEqual(true); + expect(keys.some(key => key.startsWith("people"))).toStrictEqual(false); + }); + + it("rejects two collections resolving to the same storage name", () => { + // Two explicit names. + expect(() => createTypedStorage(makeMockStorage(), { + collections: { + a: collection()({storageName: "shared", primaryKey: "name"}), + b: collection()({storageName: "shared", primaryKey: "name"}), + } + })).toThrow('Two collections resolve to the same storage name "shared".'); + + // An explicit name colliding with another collection's default (property) name, either order. + expect(() => createTypedStorage(makeMockStorage(), { + collections: { + a: collection()({storageName: "b", primaryKey: "name"}), + b: collection()({primaryKey: "name"}), + } + })).toThrow('Two collections resolve to the same storage name "b".'); + expect(() => createTypedStorage(makeMockStorage(), { + collections: { + b: collection()({primaryKey: "name"}), + a: collection()({storageName: "b", primaryKey: "name"}), + } + })).toThrow('Two collections resolve to the same storage name "b".'); + }); + + it("rejects a storage name containing a namespace delimiter", () => { + // `users.byUid` is exactly the prefix of collection `users`'s `byUid` index; an exact-name + // check would never notice, so the delimiters themselves are refused. + expect(() => createTypedStorage(makeMockStorage(), { + collections: { + users: collection()({ + primaryKey: "name", + uniqueIndexes: {byUid: (user: User) => user.uid}, + }), + alias: collection()({storageName: "users.byUid", primaryKey: "name"}), + } + })).toThrow('Collection storage name "users.byUid" must not contain "." or ":"'); + expect(() => createTypedStorage(makeMockStorage(), { + collections: { + alias: collection()({storageName: "users:alice", primaryKey: "name"}), + } + })).toThrow('Collection storage name "users:alice" must not contain "." or ":"'); + }); }); type User = { diff --git a/packages/typed-storage/src/index.ts b/packages/typed-storage/src/index.ts index 757a73d2ca..2d2737b929 100644 --- a/packages/typed-storage/src/index.ts +++ b/packages/typed-storage/src/index.ts @@ -158,8 +158,11 @@ type PrimaryKeyType> = : K extends ((record: T) => Key) ? ReturnType : never; -interface CollectionSchemaBrand { +// The part of a collection schema that doesn't depend on the record type: the brand, plus the +// options `createTypedStorage` reads at runtime, where the per-collection generics are erased. +interface CollectionSchemaBase { "__COLLECTION_SCHEMA_BRAND": never; + storageName?: string; } // TODO: Add singleton values. @@ -168,7 +171,7 @@ interface CollectionSchema< PrimaryKey extends PrimaryKeySpec, UniqueIndexes, NonUniqueIndexes - > extends CollectionSchemaBrand { + > extends CollectionSchemaBase { primaryKey: PrimaryKey; uniqueIndexes?: UniqueIndexes; nonUniqueIndexes?: NonUniqueIndexes; @@ -182,12 +185,53 @@ export function collection() { primaryKey: PrimaryKey, uniqueIndexes?: UniqueIndexes, nonUniqueIndexes?: NonUniqueIndexes, + /** + * The name this collection's keys (records and indexes alike) are prefixed with, + * overriding the schema property name. Like `SingletonOptions.storageKey`, this lets the + * code be renamed without migrating what is already on disk. + */ + storageName?: string, }) : CollectionSchema { - return options as (CollectionSchemaBrand & typeof options); + return options as (CollectionSchemaBase & typeof options); } } +/** Options for a singleton slot declared with `singleton()` rather than a bare default value. */ +export interface SingletonOptions { + /** + * The KV key this slot lives under, overriding the schema property name. Renaming a schema + * property is otherwise a storage migration, since the property name *is* the key; declaring the + * old key here renames the code without touching what is already on disk. + */ + storageKey?: string; +} + +/** + * A singleton slot declared with options. Returned by `singleton()`; a class rather than a plain + * branded object so `createTypedStorage` can tell it apart at runtime from a default value that + * happens to be an object. The private brand does the same job at the type level: without it a + * bare default shaped `{defaultValue, options}` would satisfy `SingletonSchema` structurally + * and type as `Singleton` while the runtime `instanceof` check stored the object itself. + */ +export class SingletonSchema { + declare private readonly __brand: "SingletonSchema"; + constructor(readonly defaultValue: T, readonly options: SingletonOptions) {} +} + +/** + * Declares a singleton slot that needs options. A bare default value stays the shorthand for the + * common case (`{singletons: {count: 0}}`) and behaves identically. Like a bare default, `T` is + * unconstrained, so a slot whose default is `null` or `undefined` can declare options too. + */ +export function singleton( + defaultValue: T, options: SingletonOptions = {}): SingletonSchema { + return new SingletonSchema(defaultValue, options); +} + +/** The value type a singleton slot holds: what a `SingletonSchema` wraps, or the bare default. */ +type SingletonValue = S extends SingletonSchema ? T : S; + // ======================================================================================= type CollectionImpl = TypedStorage ? CollectionImpl : never } & { - [K in keyof Singletons]: Singleton; + // Via a helper on a naked type parameter so the conditional distributes over a union default. + [K in keyof Singletons]: Singleton>; }; export function keyString(key: Key): string { @@ -665,7 +710,14 @@ function createCollection< return result; } -export function createTypedStorage, +// See the note on delimiters in `createTypedStorage`. +function checkStorageName(what: string, name: string): void { + if (name.includes(".") || name.includes(":")) { + throw new Error(`${what} "${name}" must not contain "." or ":", which delimit storage keys.`); + } +} + +export function createTypedStorage, Singletons>( storage: DurableObjectStorage, schema: { @@ -680,16 +732,46 @@ export function createTypedStorage(); for (let [colName, colSchema] of Object.entries(schema.collections || {})) { - result[colName] = createCollection(storage, colName, colSchema); + if (colSchema.storageName !== undefined) { + checkStorageName("Collection storage name", colSchema.storageName); + } + let storageName = colSchema.storageName ?? colName; + if (collectionNames.has(storageName)) { + throw new Error(`Two collections resolve to the same storage name "${storageName}".`); + } + collectionNames.add(storageName); + result[colName] = createCollection(storage, storageName, colSchema); } - for (let [key, defaultValue] of Object.entries(schema.singletons || {})) { + let singletonKeys = new Set(); + for (let [key, slotSchema] of Object.entries(schema.singletons || {})) { + let defaultValue = slotSchema instanceof SingletonSchema ? slotSchema.defaultValue : slotSchema; + if (slotSchema instanceof SingletonSchema && slotSchema.options.storageKey !== undefined) { + checkStorageName("Singleton storage key", slotSchema.options.storageKey); + } + let storageKey = slotSchema instanceof SingletonSchema + ? slotSchema.options.storageKey ?? key : key; + if (singletonKeys.has(storageKey)) { + throw new Error(`Two singletons resolve to the same storage key "${storageKey}".`); + } + singletonKeys.add(storageKey); let subscribers = new Set>(); - let singleton: Singleton = { + let slot: Singleton = { get(): any { - let result = storage.kv.get(key); + let result = storage.kv.get(storageKey); if (result === undefined) { result = defaultValue; } @@ -698,13 +780,13 @@ export function createTypedStorage { for (let subscriber of subscribers) { subscriber.update(value); } - storage.kv.put(key, value); + storage.kv.put(storageKey, value); }); } }, @@ -718,7 +800,7 @@ export function createTypedStorage false }, + containsRestrictedData: { get: () => false }, title: { get: () => "Test Workspace" }, }), ...opts.impl, diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index 75f2b12f5a..f52790ebb2 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -9,7 +9,7 @@ import { DurableObject, WorkerEntrypoint, RpcStub as NativeRpcStub, RpcTarget as NativeRpcTarget, restore, } from "cloudflare:workers"; -import { createTypedStorage, collection, keyString } from "@gadgets/typed-storage"; +import { createTypedStorage, collection, singleton, keyString } from "@gadgets/typed-storage"; import type { ListOptions } from "@gadgets/typed-storage"; import { GitStore, commitIdentityForAuthor, filesEqual, gitObjectsCollection, threeWayMerge } from "./git-store"; @@ -1151,9 +1151,9 @@ export function makeOverseerStorage(storage: DurableObjectStorage) { // single number, so the list stays cheap. deadWorktreeIds: [], - // True if any past observation was authorized that had the `prohibitAllSharing` flag set - // in its `ObservationDescription`. - prohibitAllSharing: false, + // True if any past observation was authorized that had the `containsRestrictedData` flag + // set in its `ObservationDescription`. The key on disk predates the flag's rename. + containsRestrictedData: singleton(false, {storageKey: "prohibitAllSharing"}), }, collections: { @@ -5509,7 +5509,7 @@ class OverseerImpl implements AgentHooks { async authorizeObservation(gatekeeperId: number, description: ObservationDescription, caller: GatekeeperCaller): Promise { - if (description.prohibitAllSharing) { + if (description.containsRestrictedData) { if ((await this.getSharingManager()).hasAnyShares()) { throw new Error( "This observation was blocked because it contains sensitive data that must only be " + @@ -5517,7 +5517,7 @@ class OverseerImpl implements AgentHooks { "from a workspace that is not shared."); } - this.storage.prohibitAllSharing.put(true); + this.storage.containsRestrictedData.put(true); } // Forward exclusion: the gatekeeper may name observers who must not see this observation. Since @@ -5733,7 +5733,7 @@ class OverseerImpl implements AgentHooks { // Provides web-fetch with the Workers AI binding and AI Gateway config it needs to call // `env.WORKERS_AI.toMarkdown()`. The initiator is needed for AI Gateway metadata. getWebFetchEnv(): WebFetchEnv { - if (this.storage.prohibitAllSharing.get()) { + if (this.storage.containsRestrictedData.get()) { // TODO: Disallwing fetches is a bit draconian. Ideally, we would have some way to detect // if a URL is well-known, and therefore not a leak problem. E.g. if the URL is already in // a search index, then it's not leaking anything. If we had a search provider we could @@ -5782,7 +5782,7 @@ class OverseerImpl implements AgentHooks { async submitAction(gatekeeperId: number, action: number, description: ActionDescription, caller: GatekeeperCaller) : Promise { - if (this.storage.prohibitAllSharing.get()) { + if (this.storage.containsRestrictedData.get()) { throw new Error( "This workspace has observed sensitive data. To prevent leaks, the workspace is prohibited " + "from performing actions."); @@ -9803,8 +9803,8 @@ export class OverseerDurableObject extends DurableObject { let role: CollaboratorRole = "build"; if (!isOwner) { - if (this.impl.storage.prohibitAllSharing.get()) { - // `prohibitAllSharing` can only have been set when the gadget had no shares (see + if (this.impl.storage.containsRestrictedData.get()) { + // `containsRestrictedData` can only have been set when the gadget had no shares (see // `authorizeObservation`), and no new shares can be created while it's set, so any // non-owner reaching here is necessarily unauthorized. throw createOpenGadgetError(OPEN_GADGET_ERROR_CODES.workspaceAccessDenied); @@ -9830,7 +9830,7 @@ export class OverseerDurableObject extends DurableObject { // verify they may observe everything this Gadget has read through its in-scope gatekeepers, // configuring their connected accounts if needed. Observer verification runs only after a // valid role is confirmed, so it never reveals gatekeeper or resource metadata to an - // unauthorized user; the prohibitAllSharing short-circuit above still wins over both. + // unauthorized user; the containsRestrictedData short-circuit above still wins over both. // // An unauthorized caller (no effective role -- never had access, or was removed) gets a // distinct denial without workspace metadata. A removed collaborator who reconnects after @@ -9948,7 +9948,7 @@ export class OverseerDurableObject extends DurableObject { // denial below rather than being verified (or told to fix a verification failure) for access // this path can never grant them. if (ownerId !== callerId) { - if (this.impl.storage.prohibitAllSharing.get()) { + if (this.impl.storage.containsRestrictedData.get()) { return { accepted: false, message: "This workspace has sharing disabled, so only its owner can access it.", @@ -10693,7 +10693,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { id: this.impl.ctx.id.toString(), title: this.impl.storage.title.get(), totalCost: this.impl.storage.totalCost.get(), - sharingProhibited: this.impl.storage.prohibitAllSharing.get(), + containsRestrictedData: this.impl.storage.containsRestrictedData.get(), role: "build", defaultGadgetId: this.impl.defaultGadgetId, }; @@ -10712,7 +10712,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { id: this.impl.ctx.id.toString(), title: this.impl.storage.title.get(), totalCost: this.impl.storage.totalCost.get(), - sharingProhibited: this.impl.storage.prohibitAllSharing.get(), + containsRestrictedData: this.impl.storage.containsRestrictedData.get(), role: "build", defaultGadgetId: this.impl.defaultGadgetId, }; @@ -10734,9 +10734,9 @@ class OverseerClientInterface extends RpcTarget implements Overseer { callback(metadata).catch(unsubscribe); } }; - let sharingProhibitedSubscriber = { + let restrictedDataSubscriber = { update(value: boolean | undefined) { - metadata.sharingProhibited = value; + metadata.containsRestrictedData = value; callback(metadata).catch(unsubscribe); } }; @@ -10744,13 +10744,13 @@ class OverseerClientInterface extends RpcTarget implements Overseer { let unsubscribe = () => { this.impl.storage.title.unsubscribe(titleSubscriber); this.impl.storage.totalCost.unsubscribe(costSubscriber); - this.impl.storage.prohibitAllSharing.unsubscribe(sharingProhibitedSubscriber); + this.impl.storage.containsRestrictedData.unsubscribe(restrictedDataSubscriber); callback[Symbol.dispose](); }; this.impl.storage.title.subscribe(titleSubscriber); this.impl.storage.totalCost.subscribe(costSubscriber); - this.impl.storage.prohibitAllSharing.subscribe(sharingProhibitedSubscriber); + this.impl.storage.containsRestrictedData.subscribe(restrictedDataSubscriber); callback(metadata).catch(unsubscribe); @@ -11976,7 +11976,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { // --- Collaborator management --- // // The sharing/permission logic lives in SharingManager (./sharing). These methods handle only - // the RPC-bound pieces (resolving profiles via User DOs, the `prohibitAllSharing` policy) and + // the RPC-bound pieces (resolving profiles via User DOs, the `containsRestrictedData` policy) and // delegate the rest. async listObserverRequirements( @@ -11998,7 +11998,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { return null; } - if (this.impl.storage.prohibitAllSharing.get()) { + if (this.impl.storage.containsRestrictedData.get()) { throw new Error( "This workspace has observed sensitive data. To prevent leaks, the workspace cannot be " + "shared."); @@ -12061,7 +12061,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { async createShareLink(role: CollaboratorRole, note?: string) : Promise<{ key: string; linkId: string }> { - if (this.impl.storage.prohibitAllSharing.get()) { + if (this.impl.storage.containsRestrictedData.get()) { throw new Error( "This workspace has observed sensitive data. To prevent leaks, the workspace cannot be " + "shared."); @@ -12072,7 +12072,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer { } async newShareLinkKey(linkId: string): Promise<{ key: string }> { - if (this.impl.storage.prohibitAllSharing.get()) { + if (this.impl.storage.containsRestrictedData.get()) { throw new Error( "This workspace has observed sensitive data. To prevent leaks, the workspace cannot be " + "shared."); @@ -12935,7 +12935,7 @@ class ApprovalQueueImpl extends RpcTarget implements ApprovalQueue { // the gatekeeper across awaits (even other DOs), so like the firing's callback it revalidates // the hook per call -- otherwise a firing raced by a disable/delete could keep authorizing // observations against a scope the shrink already excluded someone from (or latch - // prohibitAllSharing). Session queues (openSession) pass no hookId: they are bounded by the + // containsRestrictedData). Session queues (openSession) pass no hookId: they are bounded by the // facet's in-DO lifetime, which the session chokepoints already gate. constructor(private impl: OverseerImpl, private gatekeeperId: number, private caller: GatekeeperCaller, private hookId?: number) { diff --git a/packages/workshop-backend/src/sharing.ts b/packages/workshop-backend/src/sharing.ts index f111239223..e3fbe48d8b 100644 --- a/packages/workshop-backend/src/sharing.ts +++ b/packages/workshop-backend/src/sharing.ts @@ -15,7 +15,7 @@ // re-adding a removed collaborator restores them and, transitively, everyone they had shared with. // (Records and revoked keys accumulate in storage; a future GC could reclaim long-dead entries.) // -// NOTE: The `prohibitAllSharing` policy flag intentionally does NOT live here. It is a broader +// NOTE: The `containsRestrictedData` policy flag intentionally does NOT live here. It is a broader // "is this gadget allowed to communicate with anyone other than the owner?" policy (it also // gates gatekeeper writes and web fetches) and is expected to grow into a separate policy engine. // The Overseer enforces that flag; this module only exposes `hasAnyShares()` so the policy can @@ -165,7 +165,7 @@ export class SharingManager { /** * True if anyone other than the owner can currently access the gadget. Used by the Overseer's - * `prohibitAllSharing` policy to decide whether a sensitive observation must be blocked. + * `containsRestrictedData` policy to decide whether a sensitive observation must be blocked. * * Because removed collaborators and revoked links linger in storage (the lazy revocation model; * see the module header and removeCollaborator/revokeShareLink), this must reflect *current* @@ -293,7 +293,7 @@ export class SharingManager { /** * Add a collaborator with a `user` edge from the caller, granting `role`. The caller is * responsible for resolving `profile` (via RPC) and for any policy checks (e.g. - * `prohibitAllSharing`). The caller may not grant a role higher than their own effective role. + * `containsRestrictedData`). The caller may not grant a role higher than their own effective role. */ addCollaborator(opts: { caller: SharingCaller; diff --git a/packages/workshop-frontend/src/ShareModal.tsx b/packages/workshop-frontend/src/ShareModal.tsx index b6212442d3..e395e289e5 100644 --- a/packages/workshop-frontend/src/ShareModal.tsx +++ b/packages/workshop-frontend/src/ShareModal.tsx @@ -372,7 +372,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU }, []) const isOwner = !metadata.owner - const sharingProhibited = metadata.sharingProhibited === true + const containsRestrictedData = metadata.containsRestrictedData === true const loadData = useCallback(async () => { try { @@ -559,7 +559,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU const handleAddCollaborator = async () => { const username = addUsername.trim() - if (!username || sharingProhibited || addingRef.current) return + if (!username || containsRestrictedData || addingRef.current) return addingRef.current = true setAdding(true) @@ -585,7 +585,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU } const handleCreateShareLink = async () => { - if (sharingProhibited || creatingLinkRef.current) return + if (containsRestrictedData || creatingLinkRef.current) return creatingLinkRef.current = true setCreatingLink(true) try { @@ -611,7 +611,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU // Copy a share link again. Secrets are never stored, so the previously-shown URL can't be // re-displayed. We mint a new secret for the same logical link and copy that. const handleCopyShareLink = async (linkId: string) => { - if (sharingProhibited || copyingLinkRef.current) return + if (containsRestrictedData || copyingLinkRef.current) return copyingLinkRef.current = true setCopyingLinkId(linkId) try { @@ -775,7 +775,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU className="chat-panel min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6 sm:px-6" onScroll={(e) => setScrolled(e.currentTarget.scrollTop > 0)} > - {sharingProhibited ? ( + {containsRestrictedData ? (
@@ -821,20 +821,20 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU data-bwignore="true" data-form-type="other" className="h-9 min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 text-[14px] leading-5 tracking-[-0.25px] text-kumo-default outline-none placeholder:text-kumo-inactive disabled:cursor-not-allowed [&::-webkit-search-cancel-button]:hidden" - disabled={sharingProhibited} + disabled={containsRestrictedData} /> {adding ? 'Inviting…' : 'Invite'} @@ -911,16 +911,16 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU placeholder="Name this link (optional)…" aria-label="Share link name (optional)" className="h-9 min-w-0 flex-1 border-0 bg-transparent p-0 text-[14px] leading-5 tracking-[-0.25px] text-kumo-default outline-none placeholder:text-kumo-inactive" - disabled={creatingLink || sharingProhibited} + disabled={creatingLink || containsRestrictedData} /> - + {creatingLink ? 'Creating…' : 'Create link'} setShowLinkComposer(false)}> @@ -932,7 +932,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU
)} - - )}
diff --git a/plans/restricted-data-sharing.md b/plans/restricted-data-sharing.md index 82cbf3b5ac..84e704c86b 100644 --- a/plans/restricted-data-sharing.md +++ b/plans/restricted-data-sharing.md @@ -132,28 +132,14 @@ lazy-revocation residual in `docs/observers.md` edge case 3). Nothing in this mo - **Share modal**: no longer replaces itself with a "can't be shared" view. Controls stay live behind a notice. -- **Retained share keys** (`retainedShareKeys.ts`, new): the `#share=` fragment is - stripped from the URL on open, so a failed open had nothing to retry with. The key is - held in `sessionStorage` under a versioned, per-workspace key, and replayed on the next - attempt. (Note: under one-step redemption a failed open leaves a real edge, so the - retry is keyless — revisit this rationale when the follow-up branch rebases.) -- **Identity stamping**: because `sessionStorage` outlives the session that wrote it, each - entry records the capturing user's id. A read by a different identity ignores *and* - sweeps it, and `logout()` sweeps the whole prefix including malformed and older - unstamped entries. Without this, one user's pending share key could be auto-redeemed - under the next user's account in the same tab. -- **The in-memory tier is bound to its capturing stub**: it is replayed only on the same - `authenticatedApi` that captured it; any other stub falls through to the - identity-checked storage tier. This removes the reliance on the rendering invariant - that an identity change unmounts the editor -- true today, but enforced two files away. -- **Stamps are generation-gated**: the async identity stamp commits through a write token - taken at capture; clearing a workspace's entry (a successful open) or the logout sweep - voids every earlier token, so a stamp resolving late cannot resurrect a cleared key. - The invalidation lives in `retainedShareKeys.ts` because the storage outlives any one - attempt -- a per-attempt flag guards only its own attempt's writes. -- **A superseded open bails after its identity await**, before creating any capability: - its cleanup already ran with nothing to dispose, so proceeding would mint a stub - nothing can reach and publish a stale (or wrong-workspace) capability. +- **No share-key retention.** The `#share=` fragment is stripped from the URL on open and + sent once. If that first open fails before the server redeems the key, the retry is + keyless and the user re-clicks the invite link. A client-side retention tier + (`sessionStorage` plus an in-memory ref) was built and then dropped from + `restricted-data-followups`: keeping the key across retries, reconnects and reloads + reopened replay-after-removal and cross-user paths that took identity stamps, generation + tokens, a TTL and cross-tab broadcasts to close, all for a residual that costs one link + re-click. ## Commit sequence (one PR) @@ -186,8 +172,7 @@ path, and the scope-widening restart — landed separately in #380. `assertGrantAllowed` plumbing, the action-log scan, and the legacy flag shim. The deferred items are collected in the Known-limitations section below. The Share -modal unblock and the retained-share-key frontend work live in -`restricted-data-followups`. +modal unblock lives in `restricted-data-followups`. ## Known limitations From 5af356ea818e8ad5057868bd6b666f3a6fceae5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:15:22 -0500 Subject: [PATCH 11/15] Bump vitest from 4.1.10 to 4.1.11 (#470) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.10 to 4.1.11. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.11 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 215 ++++++++++++++++++++++++++++++++++++-------- pnpm-workspace.yaml | 2 +- 2 files changed, 179 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e2b5a5113..c31a43e6d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ catalogs: specifier: ^0.2.8 version: 0.2.8 vitest: - specifier: ^4.1.10 - version: 4.1.10 + specifier: ^4.1.11 + version: 4.1.11 wrangler: specifier: ^4.128.0 version: 4.128.0 @@ -72,7 +72,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@cloudflare/workers-types': specifier: 'catalog:' version: 5.20260903.1 @@ -81,7 +81,7 @@ importers: version: link:../../scripts vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/configurator-ui: devDependencies: @@ -93,7 +93,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/error-reporting: devDependencies: @@ -105,7 +105,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/gatekeeper-cloudflare: dependencies: @@ -130,7 +130,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -142,7 +142,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -176,7 +176,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -216,7 +216,7 @@ importers: version: 2.12.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@codemirror/commands': specifier: ^6.10.4 version: 6.10.4 @@ -306,7 +306,7 @@ importers: version: 6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -371,7 +371,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -380,7 +380,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -414,7 +414,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -429,7 +429,7 @@ importers: version: typescript@6.0.3 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -476,7 +476,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@cloudflare/workers-types': specifier: 'catalog:' version: 5.20260903.1 @@ -488,7 +488,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/gatekeeper-linear: dependencies: @@ -550,7 +550,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -584,7 +584,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -615,7 +615,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -646,7 +646,7 @@ importers: version: 2.12.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -703,7 +703,7 @@ importers: version: 6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -855,7 +855,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -889,13 +889,13 @@ importers: version: typescript@6.0.3 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/router: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -907,7 +907,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -916,7 +916,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@cloudflare/workers-types': specifier: 'catalog:' version: 5.20260903.1 @@ -925,7 +925,7 @@ importers: version: link:../../scripts vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/workshop-backend: dependencies: @@ -980,7 +980,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))) '@gadgets/scripts': specifier: workspace:* version: link:../../scripts @@ -998,7 +998,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: specifier: 'catalog:' version: 4.128.0(@cloudflare/workers-types@5.20260903.1) @@ -1016,7 +1016,7 @@ importers: version: 0.12.0 vitest-evals: specifier: 0.16.1 - version: 0.16.1(tinyrainbow@3.1.1)(vitest@4.1.10)(zod@4.4.3) + version: 0.16.1(tinyrainbow@3.1.1)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)))(zod@4.4.3) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1032,7 +1032,7 @@ importers: version: 7.0.2 vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/workshop-frontend: dependencies: @@ -1168,7 +1168,7 @@ importers: version: 7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) packages/workshop-shared: dependencies: @@ -3047,6 +3047,9 @@ packages: '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@4.1.10': resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: @@ -3058,21 +3061,47 @@ packages: vite: optional: true + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: 7.3.6 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@4.1.10': resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@4.1.10': resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@4.1.10': resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@voidzero-dev/vite-plus-core@0.2.8': resolution: {integrity: sha512-hqUJyozjE4HtJ3wwf2pOw6LnH/EF9W4+ys2s8arr/3fUdw64vzp/SfPFBVCxsGRv2UfjkIieOeZrQ1FH6Oa5Tw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -5181,6 +5210,47 @@ packages: jsdom: optional: true + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': 26.1.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: 7.3.6 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5757,14 +5827,14 @@ snapshots: optionalDependencies: workerd: 1.20260831.1 - '@cloudflare/vitest-pool-workers@0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + '@cloudflare/vitest-pool-workers@0.22.0(@cloudflare/workers-types@5.20260903.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 cjs-module-lexer: 1.2.3 esbuild: 0.28.1 miniflare: 5.20260831.0-alpha - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) wrangler: 4.128.0(@cloudflare/workers-types@5.20260903.1) zod: 4.4.3 transitivePeerDependencies: @@ -7113,6 +7183,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -7121,15 +7200,32 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/runner@4.1.10': dependencies: '@vitest/utils': 4.1.10 pathe: 2.0.3 + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + '@vitest/snapshot@4.1.10': dependencies: '@vitest/pretty-format': 4.1.10 @@ -7137,14 +7233,29 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} + '@vitest/utils@4.1.10': dependencies: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@voidzero-dev/vite-plus-core@0.2.8(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.2)(typescript@7.0.2)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.142.0 @@ -9396,12 +9507,12 @@ snapshots: terser: 5.49.2 yaml: 2.9.0 - vitest-evals@0.16.1(tinyrainbow@3.1.1)(vitest@4.1.10)(zod@4.4.3): + vitest-evals@0.16.1(tinyrainbow@3.1.1)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)))(zod@4.4.3): dependencies: '@vitest-evals/core': 0.16.1 '@vitest-evals/report-ui': 0.16.1 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) optionalDependencies: zod: 4.4.3 @@ -9435,6 +9546,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-preview@4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10))(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.0 + '@vitest/browser-preview': 4.1.10(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))(vitest@4.1.10) + jsdom: 26.1.0(supports-color@10.2.2) + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d136a36dac..152527dea5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ catalog: # decorators capnweb-validate's `@validateRpc()` relies on (cloudflare/workers-sdk#12626). vite: 7.3.6 vite-plus: ^0.2.8 - vitest: ^4.1.10 + vitest: ^4.1.11 wrangler: ^4.128.0 overrides: From 98c3482076fc7b00c078acbf128fd209788335c5 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Mon, 14 Sep 2026 13:24:31 -0400 Subject: [PATCH 12/15] feat(fork): codify the upstream merge strategy - Move the Tier-1 boundary (owned prefixes, removed paths, format exceptions) from audit consts into scripts/fork/fork-boundary.json, read by both the audit and the sync script. - Add a Tier-1 collision check to the audit: a fork-owned prefix that also exists upstream fails, with Tier-2 remediation. This reclassifies the four wrangler.jsonc files (briefly and wrongly Tier 1) back to fork-managed upstream files; the divergence inventory records them. - Add pnpm fork:sync: impact report (--dry-run), policy-driven merge (only deliberate removals auto-resolve), and --verify (removed-name survivors, uncached typecheck, audit). - Document the Tier-1/Tier-2 policy and the new flow in docs/fork-maintenance.md and README.md. --- README.md | 16 +- docs/fork-maintenance.md | 110 ++-- package.json | 3 +- scripts/fork/fork-boundary.json | 92 +++ scripts/fork/fork-boundary.test.ts | 101 +++ scripts/fork/fork-boundary.ts | 147 +++++ scripts/fork/sync-upstream.test.ts | 453 +++++++++++++ scripts/fork/sync-upstream.ts | 756 ++++++++++++++++++++++ scripts/fork/upstream-merge-audit.test.ts | 58 +- scripts/fork/upstream-merge-audit.ts | 133 ++-- 10 files changed, 1720 insertions(+), 149 deletions(-) create mode 100644 scripts/fork/fork-boundary.json create mode 100644 scripts/fork/fork-boundary.test.ts create mode 100644 scripts/fork/fork-boundary.ts create mode 100644 scripts/fork/sync-upstream.test.ts create mode 100644 scripts/fork/sync-upstream.ts diff --git a/README.md b/README.md index 2a0ae764fc..8ccbdf37a4 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,18 @@ afterwards. `foundation` is the upstream remote (`cloudflare/cloudflare-os`); `origin` is ours. To sync: ```bash -git fetch foundation -git merge foundation/main -pnpm fork:audit # catches the failures git does NOT flag +pnpm fork:sync --dry-run # impact report, no branch, no merge +pnpm fork:sync # fetch, branch, merge; resolve Tier-2 files by hand +pnpm fork:sync --verify # exit 0 means commit mise x node@24 -- pnpm lint && mise x node@24 -- pnpm test ``` -`pnpm fork:audit` exists because the expensive problems in a sync are the silent ones. It reports -upstream changes that vanished without ever raising a conflict (a file resolved as "take ours" drops -every upstream hunk in it), and upstream-owned files whose entire diff is reformatting — churn that -buys nothing and conflicts forever. +`pnpm fork:sync` exists because the expensive problems in a sync are the silent ones: upstream +changes that vanish without ever raising a conflict (a file resolved as "take ours" drops every +upstream hunk in it), upstream-removed names the fork still uses (a rename surfaces here, with the +removing commit, instead of as confusing test failures), and upstream-owned files whose entire diff +is reformatting — churn that buys nothing and conflicts forever. `pnpm fork:audit` runs the +post-hoc checks standalone (and in CI on every PR). Two environment notes that will otherwise cost you an afternoon: diff --git a/docs/fork-maintenance.md b/docs/fork-maintenance.md index 957e96584b..0680a6a0e9 100644 --- a/docs/fork-maintenance.md +++ b/docs/fork-maintenance.md @@ -40,25 +40,17 @@ that silently drops an upstream guarantee.** Our commit `a811be9` replaced upstr ### 1. Put new work in fork-owned trees -Upstream has no file there, so nothing in them can ever conflict. Today: - -- `packages/gatekeeper-ai-executor/` — the AI Executor gatekeeper, ~19k lines, zero conflict surface. -- `packages/integration-tests/__tests__/fork/` — fork integration tests. -- `packages/workshop-backend/src/fork/` — approval-turn continuation (approval-continuation.ts). -- `packages/workshop-backend/__integration__/knitli-admin-gatekeeper-frame.test.ts` and `packages/workshop-frontend/src/features/admin/gatekeeper-apps/` — vendor-owned deployment-admin frames and regressions. -- `packages/workshop-backend/__tests__/knitli-approval-continuation.test.ts` — approval-turn continuation regressions. -- `packages/mcp-shared/__tests__/fork/` — named `$defs` aliases, read-before-dispatch authorization, and the caller-settable argument budget. -- `scripts/fork/` — fork tooling. -- `docs/fork-maintenance.md` — this file. -- `packages/backend-utils/src/access.ts` — the Cloudflare Access assertion verifier (moved here from - `workshop-backend` so gatekeepers can share it). -- `packages/backend-utils/src/fork/` — the shared connect-initiator guard - (`connect-initiator.ts`), used by every gatekeeper that owns its own account Durable Object. -- `packages/gatekeeper-{github,linear,cloudflare}/__tests__/workerd/knitli-connect-initiator.test.ts` - — connect-initiator enforcement regressions. - -This list is also encoded as `FORK_OWNED_PREFIXES` in `scripts/fork/upstream-merge-audit.ts`. Add to -both when you add a tree. +Two tiers. **Tier 1** is paths the fork owns outright: upstream has no file there, so nothing in +them can ever conflict -- which is exactly why new work belongs in them. **Tier 2** is everything +else the fork touches: fork-modified upstream files, always resolved by hand at each sync, even +when that ends at "take ours". + +Tier 1 is declared in `scripts/fork/fork-boundary.json` -- the AI Executor gatekeeper, the +`__tests__/fork/` regression trees, the backend `src/fork/` policy modules, the connect-initiator +guard and its tests, the sync tooling itself, and this file, each with its reason. The merge audit +and the sync script both read it; nothing duplicates it. Add the entry when you add a tree, and +the audit verifies the claim: a Tier-1 path that also exists upstream is a collision, and the +path drops to Tier 2 until the config is fixed. ### 2. Never reformat an upstream-owned file @@ -66,8 +58,8 @@ Turn off format-on-save for this repo, or scope it to the fork-owned trees. A di upstream file should contain only lines whose *meaning* you changed. `pnpm fork:audit` fails on any upstream file whose entire diff normalises away to nothing. -An intentional comment-only contract correction can be recorded in `FORMAT_EXCEPTIONS` in -`scripts/fork/upstream-merge-audit.ts`, with its exact path, upstream and fork Git blob IDs, and +An intentional comment-only contract correction can be recorded in `formatExceptions` in +`scripts/fork/fork-boundary.json`, with its exact path, upstream and fork Git blob IDs, and review reason. Only that content pair is exempt from the formatting check; changing either blob requires review again. The audit prints the reason when it applies. This does not change file ownership or exempt the file from dropped-hunk checking. @@ -114,26 +106,48 @@ as a conflict. ## Syncing with upstream ```bash -git fetch foundation -git checkout -b sync/foundation-$(date +%Y-%m-%d) -git merge foundation/main +pnpm fork:sync --dry-run # impact report, no branch, no merge: scope the sync first +pnpm fork:sync # fetch, branch, merge, policy resolutions +# ... resolve the Tier-2 files by hand ... +pnpm fork:sync --verify # survivors, uncached typecheck, audit; exit 0 means commit ``` Then, in order: -1. **Resolve the marked conflicts.** For a file where upstream restructured and we added, start from - upstream's version and re-apply our addition on top — not the other way round. It keeps our diff - small and matches upstream's shape. Use `difft` rather than `git diff` while doing it; structural - diff hides reflow and shows the change. - -2. **Audit for the silent failures.** This is the step that is easy to skip and expensive to skip: - - ```bash - pnpm fork:audit - ``` - - It reports upstream hunks that vanished without a conflict, and upstream-owned files whose diff is - pure reflow. Run it *before* the checks below — a dropped hunk usually still typechecks. +1. **Scope it.** `--dry-run` prints the impact report without touching anything: the upstream + commits, the files changed on both sides (the reconcile set -- review each, conflict or silent + auto-merge), the upstream modules Tier-1 files import, and fork-added files outside Tier 1. + Predicted review surface, not conflicts. + +2. **Merge.** `pnpm fork:sync` fetches `foundation`, refuses shallow clones and dirty trees, + creates `sync/foundation-`, and merges with `--no-commit` so even a clean merge waits + for verification. The only automatic resolutions are deliberately-removed files upstream + touched (kept deleted, recorded in the commit message). Everything else that conflicts stops + for hand resolution. Re-running mid-merge reports what is left instead of starting over. + +3. **Resolve the marked conflicts.** Tier 2, by hand: for a file where upstream restructured and + we added, start from upstream's version and re-apply our addition on top — not the other way + round. It keeps our diff small and matches upstream's shape. Use `difft` rather than `git diff` + while doing it; structural diff hides reflow and shows the change. A Tier-1 path here + contradicts the boundary claim -- resolve it, then drop the entry from `fork-boundary.json`. + +4. **Verify.** `pnpm fork:sync --verify` runs three stages: upstream-removed names the fork still + uses, each with the upstream commit that removed it (multi-segment names gone or nearly gone + from upstream -- renames land here before they land in confusing test failures, while renamed + single words surface through the typecheck below); an uncached typecheck of every package plus + the repo scripts (a sync breaks packages it never touches, whose recorded passes the task + cache would otherwise replay); + and the merge audit below. It understands a merge in progress (worktree), a committed sync, + and -- with no merge anywhere -- a preview of HEAD against upstream. Exit 0 means commit; the + policy notes are pre-filled in the message. Run it *before* the checks below — a dropped hunk + usually still typechecks. + +### The merge audit + +`pnpm fork:audit` -- also the last `--verify` stage, and a CI job on every PR -- reports four +things: upstream hunks that vanished without a conflict, upstream-owned files whose diff is pure +reflow, deliberately-removed files that came back, and Tier-1 collisions (boundary entries gone +wrong). It finds the merge on its own, whether one is in progress (resolutions in the index) or already committed (resolutions in the merge commit), so it works during the sync and afterwards on the PR. @@ -187,10 +201,11 @@ Then, in order: something that was not a sync is recoverable; silently skipping a real one is the failure this tool exists to prevent. -3. **Re-verify the divergence inventory.** For each entry, confirm it is still present and still +5. **Re-verify the divergence inventory.** For each entry, confirm it is still present and still necessary; upstream may have adopted, moved, or obsoleted it. -4. **Run the checks, on Node 24.** The repo targets Node 24; Node 26 ships a global `localStorage` +6. **Run the checks, on Node 24.** `--verify` covered the typecheck; the linter and the full suite + still run here. The repo targets Node 24; Node 26 ships a global `localStorage` that shadows jsdom's and fails ~12 frontend tests for reasons that have nothing to do with your change. @@ -284,10 +299,10 @@ Intentional, reviewed differences from upstream. Keep this current. that is now gone, so they are inert, and they point contributors at Cloudflare's issue tracker. Left in place deliberately: whether this fork accepts outside contributions is a call for the maintainers, not a cleanup. -- **How it is kept:** listed in `REMOVED_UPSTREAM_PATHS` in `scripts/fork/upstream-merge-audit.ts`, +- **How it is kept:** listed in `removedUpstreamPaths` in `scripts/fork/fork-boundary.json`, with the reason. When upstream touches one of these, a sync raises a modify/delete conflict, which is visible — but resolving that toward upstream restores the file silently, which is not. The - audit fails if one comes back. + audit fails if one comes back, and the sync script keeps them deleted automatically. - **Left alone deliberately:** `.github/dependabot.yml` still carries an `ignore` entry for `ask-bonk/ask-bonk`. It is inert once the workflows are gone, and removing it would add a divergence to an upstream-owned file to no benefit. @@ -308,6 +323,19 @@ Intentional, reviewed differences from upstream. Keep this current. - **Why:** Both are data-only additions to existing upstream sets — the cheapest possible shape for an upstream edit, and the shape to aim for elsewhere. +### Deployment wrangler files are fork-managed upstream files (Tier 2) + +- **Where:** `wrangler.jsonc`, `packages/workshop-backend/wrangler.jsonc`, + `packages/gatekeeper-context/wrangler.jsonc`, `packages/router/wrangler.jsonc` +- **What:** deployment bindings, vars, and limits differ from upstream's (assets and AI bindings, + time limits, context artifacts). +- **Why:** this deployment binds workers differently than upstream's; the files cannot be + byte-identical. +- **How it is kept:** Tier 2, resolved by hand at each sync — usually take ours, but an upstream + feature (a new binding, a raised limit) is reconciled in, not dropped. These were briefly listed + as Tier 1 in `ae28b29b`, which only exempted take-ours resolutions from the dropped-hunk check + without avoiding any conflict; the collision check now fails that shape. + ### Named `$defs` aliases in `generateSessionTypes` - **Where:** `generateSessionTypes` in `packages/mcp-shared/src/schema-to-ts.ts` diff --git a/package.json b/package.json index 119dc8b1dd..16be785e85 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "lint": "pnpm run lint:check && pnpm run types:scripts && pnpm run types:check", "types:generate": "node scripts/generate-worker-types.ts", "types:generated": "node scripts/generate-worker-types.ts --check --package gatekeeper-ai-executor", - "fork:audit": "node scripts/fork/upstream-merge-audit.ts" + "fork:audit": "node scripts/fork/upstream-merge-audit.ts", + "fork:sync": "node scripts/fork/sync-upstream.ts" }, "devDependencies": { "@gadgets/scripts": "workspace:*", diff --git a/scripts/fork/fork-boundary.json b/scripts/fork/fork-boundary.json new file mode 100644 index 0000000000..37d94de4f8 --- /dev/null +++ b/scripts/fork/fork-boundary.json @@ -0,0 +1,92 @@ +{ + "forkOwned": [ + { + "path": "packages/gatekeeper-kit/__tests__/workerd/credential-mutation.test.ts", + "reason": "Exact new test path for the credential mutation transaction hook; the source and existing tests stay upstream-audited." + }, + { + "path": "patches/capnweb-validate@0.3.0.patch", + "reason": "Fork-local pnpm patch so native Workers capabilities keep their runtime identity through validation; pinned by scripts/fork/capnweb-native-validation.test.ts." + }, + { + "path": "scripts/fork/capnweb-native-validation.test.ts", + "reason": "Regression test for the fork-local capnweb-validate patch above." + }, + { + "path": "packages/workshop-backend/src/fork/", + "reason": "Fork-owned backend modules (approval-turn continuation); policy lives here behind a one-line upstream seam." + }, + { + "path": "packages/workshop-backend/__integration__/knitli-admin-gatekeeper-frame.test.ts", + "reason": "Regression test for deployment-admin gatekeeper frames, which upstream does not have." + }, + { + "path": "packages/workshop-frontend/src/features/admin/gatekeeper-apps/", + "reason": "Deployment-admin connector frames UI; no upstream counterpart." + }, + { + "path": "packages/workshop-backend/__tests__/knitli-approval-continuation.test.ts", + "reason": "Regression test for the fork-owned approval-turn continuation." + }, + { + "path": "packages/mcp-shared/__tests__/fork/", + "reason": "Fork regression tests for named $defs aliases, read-before-dispatch authorization, and the caller-settable argument budget." + }, + { + "path": "packages/backend-utils/src/access.ts", + "reason": "Cloudflare Access assertion verifier shared by the Workshop and gatekeepers; upstream has no Access integration." + }, + { + "path": "packages/backend-utils/src/fork/", + "reason": "Shared connect-initiator guard used by every gatekeeper that owns its own account Durable Object." + }, + { + "path": "packages/gatekeeper-github/__tests__/workerd/knitli-connect-initiator.test.ts", + "reason": "Connect-initiator enforcement regression for the hand-rolled GitHub account." + }, + { + "path": "packages/gatekeeper-linear/__tests__/workerd/knitli-connect-initiator.test.ts", + "reason": "Connect-initiator enforcement regression for the hand-rolled Linear account." + }, + { + "path": "packages/gatekeeper-cloudflare/__tests__/workerd/knitli-connect-initiator.test.ts", + "reason": "Connect-initiator enforcement regression for the hand-rolled Cloudflare account." + }, + { + "path": "packages/gatekeeper-ai-executor/", + "reason": "The AI Executor gatekeeper, bound by the outer deployment rather than deployed as its own worker; zero conflict surface by construction." + }, + { + "path": "packages/integration-tests/__tests__/fork/", + "reason": "Fork integration tests kept out of upstream-owned suites so those files stay byte-identical." + }, + { + "path": "scripts/fork/", + "reason": "Fork tooling: this boundary, the merge audit, and the sync script. Upstream must never grow a file here." + }, + { + "path": ".github/workflows/fork-audit.yml", + "reason": "Runs the fork audit in CI with full history and the upstream remote; deliberately not a step in upstream's ci.yml." + }, + { + "path": "docs/fork-maintenance.md", + "reason": "The fork's maintenance playbook, documenting the divergence policy this file encodes." + } + ], + "removedUpstreamPaths": { + ".github/workflows/cla.yml": "Cloudflare's CLA assistant: signs against cloudflare.com/cla and stores signatures on a `cla-signatures` branch this fork does not have, so it only ever fails here.", + ".github/workflows/bonk.yml": "Cloudflare's internal review bot, which needs a GitHub App installation this fork lacks.", + ".github/workflows/bonk-pr.yml": "The PR half of the same bot; its break-glass path also assumes that App.", + ".github/workflows/contribution-policy.yml": "Enforces Cloudflare's policy on Cloudflare's repository -- it closes outside PRs and points contributors at cloudflare/cloudflare-os. Whether this fork takes contributions is our call.", + "scripts/contribution-policy.ts": "Only consumer was contribution-policy.yml, via actions/github-script.", + "scripts/contribution-policy.test.ts": "Tests the above, and reads the workflow file, so it cannot outlive either." + }, + "formatExceptions": [ + { + "path": "packages/workshop-backend/src/worktree-binding.d.ts", + "upstreamBlob": "ff54d738edfe0880bf56120e091818c891a7bfb1", + "forkBlob": "e7046837ca08f49c6dc657142e9402b2029fceb1", + "reason": "Document the enforced full 40-hex commit capability boundary for Worktree.diff()." + } + ] +} diff --git a/scripts/fork/fork-boundary.test.ts b/scripts/fork/fork-boundary.test.ts new file mode 100644 index 0000000000..a8c1c83837 --- /dev/null +++ b/scripts/fork/fork-boundary.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { forkBoundary, parseForkBoundary } from "./fork-boundary.ts"; + +test("the live boundary loads and every entry records why", () => { + const boundary = forkBoundary(); + assert.ok(boundary.forkOwned.length > 0, "Tier 1 should not be silently emptied"); + const seen = new Set(); + for (const entry of boundary.forkOwned) { + assert.ok(!seen.has(entry.path), `duplicate Tier-1 entry: ${entry.path}`); + seen.add(entry.path); + // The reason is the whole point: a bare list rots into "why is this here?" within a sync or two. + assert.ok(entry.reason.length > 30, + `${entry.path} needs a reason someone can act on, got: ${entry.reason}`); + } + const removed = Object.entries(boundary.removedUpstreamPaths); + assert.ok(removed.length > 0, "the removed list should not be silently emptied"); + for (const [path, reason] of removed) { + assert.ok(reason.length > 30, `${path} needs a reason someone can act on, got: ${reason}`); + } +}); + +test("the loader and the parser agree on the live config", () => { + const text = readFileSync(new URL("./fork-boundary.json", import.meta.url), "utf8"); + assert.deepEqual(parseForkBoundary(text), forkBoundary()); + assert.equal(forkBoundary(), forkBoundary(), "the boundary loads once"); +}); + +function validBoundary(): Record { + return { + forkOwned: [{ path: "packages/a-feature/", reason: "Upstream has no such tree." }], + removedUpstreamPaths: { "some/removed.yml": "Only ever fails here." }, + formatExceptions: [], + }; +} + +test("a minimal valid boundary parses", () => { + const boundary = parseForkBoundary(JSON.stringify(validBoundary())); + assert.deepEqual(boundary.forkOwned, [ + { path: "packages/a-feature/", reason: "Upstream has no such tree." }, + ]); + assert.deepEqual(boundary.removedUpstreamPaths, { "some/removed.yml": "Only ever fails here." }); + assert.deepEqual(boundary.formatExceptions, []); +}); + +for (const [name, mutate, pattern] of [ + ["unknown top-level key", (b: Record) => { + b["forkowned"] = b["forkOwned"]; + delete b["forkOwned"]; + }, /unknown key "forkowned"/], + ["missing Tier 1", (b: Record) => { + delete b["forkOwned"]; + }, /forkOwned must be a non-empty array/], + ["emptied Tier 1", (b: Record) => { + b["forkOwned"] = []; + }, /forkOwned must be a non-empty array/], + ["Tier-1 entry must be an object", (b: Record) => { + b["forkOwned"] = ["packages/a-feature/"]; + }, /forkOwned\[0\] must be an object/], + ["Tier-1 entry rejects unknown keys", (b: Record) => { + b["forkOwned"] = [{ path: "packages/a-feature/", reason: "x", since: "today" }]; + }, /forkOwned\[0\]: unknown key "since"/], + ["Tier-1 path must be a path, not a bare name", (b: Record) => { + b["forkOwned"] = [{ path: "a-feature", reason: "Upstream has no such tree." }]; + }, /forkOwned\[0\]\.path must name a directory or file path/], + ["Tier-1 entry needs a reason", (b: Record) => { + b["forkOwned"] = [{ path: "packages/a-feature/", reason: "" }]; + }, /forkOwned\[0\]\.reason needs a reason/], + ["removed paths must be an object", (b: Record) => { + b["removedUpstreamPaths"] = []; + }, /removedUpstreamPaths must be an object/], + ["removed path must be a path", (b: Record) => { + b["removedUpstreamPaths"] = { "removed.yml": "Only ever fails here." }; + }, /must name a directory or file path/], + ["removed path needs a reason", (b: Record) => { + b["removedUpstreamPaths"] = { "some/removed.yml": "" }; + }, /needs a reason someone can act on/], + ["exceptions must be an array", (b: Record) => { + b["formatExceptions"] = {}; + }, /formatExceptions must be an array/], + ["exception blob ids must be full SHAs", (b: Record) => { + b["formatExceptions"] = [{ + path: "src/a.ts", + upstreamBlob: "abc123", + forkBlob: "0".repeat(40), + reason: "Reviewed.", + }]; + }, /upstreamBlob must be a full 40-hex blob id/], +] as const) { + test(`malformed boundary is rejected: ${name}`, () => { + const boundary = validBoundary(); + mutate(boundary); + assert.throws(() => parseForkBoundary(JSON.stringify(boundary)), pattern); + }); +} + +test("malformed JSON names itself", () => { + assert.throws(() => parseForkBoundary("{oops"), /not valid JSON/); + assert.throws(() => parseForkBoundary("[]"), /must be an object at the top level/); +}); diff --git a/scripts/fork/fork-boundary.ts b/scripts/fork/fork-boundary.ts new file mode 100644 index 0000000000..31fd02eef0 --- /dev/null +++ b/scripts/fork/fork-boundary.ts @@ -0,0 +1,147 @@ +/** + * The fork's Tier-1 boundary: paths the fork owns outright, upstream files it deliberately + * removed, and reviewed comment-only divergences. Single source of truth, read by the merge + * audit (`upstream-merge-audit.ts`), the sync script (`sync-upstream.ts`), and their tests; + * `docs/fork-maintenance.md` points here instead of duplicating the lists. + * + * Two tiers. Tier 1 is declared here: paths upstream must have no file at or under, so nothing + * in them can ever conflict. Tier 2 is computed per sync, never declared: fork-modified upstream + * files (everything the fork touches that is not Tier 1), which are always resolved by hand. A + * Tier-1 entry that also exists upstream contradicts the claim it makes, so the audit reports it + * as a collision and the path drops to Tier 2 until the config is fixed. + * + * JSON, not a TS module, so shell and git plumbing can read it too. Validation is strict and + * fails closed: a missing, malformed, or silently-emptied boundary throws rather than auditing + * against an unknown one. + */ + +import { readFileSync } from "node:fs"; + +/** A path prefix the fork owns outright: Tier 1. Upstream must have no file at or under it. */ +export interface ForkOwnedPrefix { + path: string; + reason: string; +} + +/** One reviewed comment-only divergence; changing either blob requires a fresh review. */ +export interface FormatException { + path: string; + upstreamBlob: string; + forkBlob: string; + reason: string; +} + +export interface ForkBoundary { + forkOwned: ForkOwnedPrefix[]; + removedUpstreamPaths: Record; + formatExceptions: FormatException[]; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function checkKeys(where: string, value: Record, allowed: string[]): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw new Error(`${where}: unknown key ${JSON.stringify(key)} (expected one of ${allowed.join(", ")})`); + } + } +} + +function checkPath(where: string, value: unknown): string { + if (typeof value !== "string" || value.length === 0 || !value.includes("/")) { + throw new Error( + `${where} must name a directory or file path so it cannot match unrelated packages`); + } + return value; +} + +function checkReason(where: string, value: unknown): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${where} needs a reason someone can act on`); + } + return value; +} + +/** Parses and validates boundary JSON. Pure, so malformed input is testable without files. */ +export function parseForkBoundary(text: string): ForkBoundary { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`not valid JSON: ${(error as Error).message}`, { cause: error }); + } + if (!isObject(parsed)) throw new Error("must be an object at the top level"); + checkKeys("boundary", parsed, ["forkOwned", "removedUpstreamPaths", "formatExceptions"]); + + if (!Array.isArray(parsed["forkOwned"]) || parsed["forkOwned"].length === 0) { + throw new Error("forkOwned must be a non-empty array: an absent Tier 1 is never a steady state"); + } + const forkOwned = parsed["forkOwned"].map((entry: unknown, i: number): ForkOwnedPrefix => { + if (!isObject(entry)) throw new Error(`forkOwned[${i}] must be an object`); + checkKeys(`forkOwned[${i}]`, entry, ["path", "reason"]); + return { + path: checkPath(`forkOwned[${i}].path`, entry["path"]), + reason: checkReason(`forkOwned[${i}].reason`, entry["reason"]), + }; + }); + + if (!isObject(parsed["removedUpstreamPaths"])) { + throw new Error("removedUpstreamPaths must be an object"); + } + const removedUpstreamPaths: Record = {}; + for (const [path, reason] of Object.entries(parsed["removedUpstreamPaths"])) { + checkPath("removedUpstreamPaths key", path); + removedUpstreamPaths[path] = checkReason(`removedUpstreamPaths[${path}]`, reason); + } + + if (!Array.isArray(parsed["formatExceptions"])) { + throw new Error("formatExceptions must be an array"); + } + const formatExceptions = parsed["formatExceptions"].map((entry: unknown, i: number): FormatException => { + if (!isObject(entry)) throw new Error(`formatExceptions[${i}] must be an object`); + checkKeys(`formatExceptions[${i}]`, entry, ["path", "upstreamBlob", "forkBlob", "reason"]); + for (const key of ["upstreamBlob", "forkBlob"] as const) { + if (typeof entry[key] !== "string" || !/^[0-9a-f]{40}$/.test(entry[key])) { + throw new Error(`formatExceptions[${i}].${key} must be a full 40-hex blob id`); + } + } + return { + path: checkPath(`formatExceptions[${i}].path`, entry["path"]), + upstreamBlob: entry["upstreamBlob"] as string, + forkBlob: entry["forkBlob"] as string, + reason: checkReason(`formatExceptions[${i}].reason`, entry["reason"]), + }; + }); + + return { forkOwned, removedUpstreamPaths, formatExceptions }; +} + +const CONFIG_URL = new URL("./fork-boundary.json", import.meta.url); + +let cached: ForkBoundary | undefined; + +/** + * The live boundary, loaded once. Throws when the config cannot be read or validated -- + * callers inside the audit CLI convert that to exit 2 ("could not look"), never a pass. + */ +export function forkBoundary(): ForkBoundary { + if (!cached) { + let text: string; + try { + text = readFileSync(CONFIG_URL, "utf8"); + } catch (error) { + throw new Error(`cannot read the fork boundary at ${CONFIG_URL.pathname}: ` + + `${(error as Error).message}; refusing to audit against an unknown boundary`, + { cause: error }); + } + try { + cached = parseForkBoundary(text); + } catch (error) { + throw new Error(`invalid fork boundary at ${CONFIG_URL.pathname}: ${(error as Error).message}`, + { cause: error }); + } + } + return cached; +} diff --git a/scripts/fork/sync-upstream.test.ts b/scripts/fork/sync-upstream.test.ts new file mode 100644 index 0000000000..1e8d3abeb7 --- /dev/null +++ b/scripts/fork/sync-upstream.test.ts @@ -0,0 +1,453 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { forkBoundary } from "./fork-boundary.ts"; +import { + applyMergePolicy, + attributeRemoval, + auditCommand, + findSurvivors, + findTier1References, + forkTouchedFiles, + isCompoundName, + mergeHeadPresent, + planSync, + preflightStart, + removedIdentifiers, + resolveVerifyContext, + survivingTokens, + typecheckPlan, + unmergedPaths, +} from "./sync-upstream.ts"; +import { UsageError } from "./upstream-merge-audit.ts"; + +// Fixture paths follow the live boundary rather than hardcoding it: the Tier-1 probe lives under +// the first Tier-1 directory, and the removed file is the first recorded removal. +const TIER1_DIR = forkBoundary().forkOwned.find(entry => entry.path.endsWith("/"))!.path; +const PROBE = `${TIER1_DIR}sync-probe.ts`; +const REMOVED = Object.keys(forkBoundary().removedUpstreamPaths)[0]!; +const SHARED = "shared.ts"; + +function scratchRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "fork-sync-repo-")); + const run = (...args: string[]) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }); + run("init", "-q", "-b", "main"); + run("config", "user.email", "test@example.invalid"); + run("config", "user.name", "Test"); + return dir; +} + +function inRepo(dir: string, body: () => T): T { + const previous = process.cwd(); + process.chdir(dir); + try { + return body(); + } finally { + process.chdir(previous); + } +} + +interface SyncFixture { + dir: string; + run: (...args: string[]) => string; + base: string; + tip: string; + ours: string; +} + +/** + * A miniature sync: a shared base, an upstream branch that renames a name, touches a removed + * file, reorders tokens, and adds a module, and a fork tip that edits the renamed file's line, + * deletes the removed file plus an unrecorded one, and adds Tier-1 and stray files. With `clash`, + * both sides also add the same Tier-1 path with different content (an add/add collision). + */ +function syncRepo(clash: boolean): SyncFixture { + const dir = scratchRepo(); + const run = (...args: string[]) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }).trim(); + const write = (path: string, content: string) => { + const file = join(dir, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); + }; + write(SHARED, `export const oldWidgetName = "old";\nexport const forkSetting = 1;\n` + + `export const shared = oldWidgetName;\n`); + write(REMOVED, "name: cla\non: push\n"); + write("unrelated.ts", "export const unrelated = 1;\n"); + write("shuffled.ts", "export const alphaToken = 1;\nexport const betaToken = 2;\n"); + run("add", "."); + run("commit", "-q", "-m", "base"); + const base = run("rev-parse", "HEAD"); + + run("checkout", "-q", "-b", "upstream"); + write(SHARED, `export const newWidgetName = "old";\nexport const forkSetting = 1;\n` + + `export const shared = newWidgetName;\n`); + run("commit", "-qam", "upstream: rename oldWidgetName"); + write(REMOVED, "name: cla\non: pull_request\n"); + run("commit", "-qam", "upstream: touch removed workflow"); + write("shuffled.ts", "export const betaToken = 2;\nexport const alphaToken = 1;\n"); + write("src/upstreamwidget.ts", "export const upstreamwidget = 1;\n"); + if (clash) write(`${TIER1_DIR}clash.ts`, `export const clashMarker = "upstream";\n`); + run("add", "."); + run("commit", "-q", "-m", "upstream: shuffle and add"); + const tip = run("rev-parse", "upstream"); + + run("checkout", "-q", "main"); + write(SHARED, `export const oldWidgetName = "fork-old";\nexport const forkSetting = 1;\n` + + `export const shared = oldWidgetName;\n`); + run("rm", "-q", REMOVED, "unrelated.ts"); + write(PROBE, "// Tier-1 probe for the sync tests.\n" + + `import { upstreamwidget } from "upstreamwidget";\n` + + "export const probe = oldWidgetName;\n" + + "export const widget = upstreamwidget;\n" + + `export const note = "shuffled mentions alone are not references";\n`); + write("src/stray.ts", "export const stray = 1;\n"); + if (clash) write(`${TIER1_DIR}clash.ts`, `export const clashMarker = "fork";\n`); + run("add", "."); + run("commit", "-q", "-m", "fork work"); + const ours = run("rev-parse", "HEAD"); + return { dir, run, base, tip, ours }; +} + +test("fork changes split into Tier 2, undeclared adds, and unrecorded deletions", () => { + const { dir, base, ours, tip } = syncRepo(true); + try { + inRepo(dir, () => { + assert.deepEqual(forkTouchedFiles(base, ours, tip), { + tier2: [SHARED], + undeclaredAdds: ["src/stray.ts"], + unrecordedDeletions: ["unrelated.ts"], + }); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("removed identifiers are net removals: renames fire, moves do not", () => { + const { dir, base, tip } = syncRepo(false); + try { + inRepo(dir, () => { + // Exactly the rename: the reordered tokens net to zero, short tokens stay out, and added + // tokens are never candidates. + assert.deepEqual(removedIdentifiers(base, tip), ["oldWidgetName"]); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("survivors are found at a ref and in the worktree", () => { + const { dir, base, tip } = syncRepo(false); + try { + inRepo(dir, () => { + const tokens = removedIdentifiers(base, tip); + const expected = [{ token: "oldWidgetName", files: [PROBE, SHARED].toSorted() }]; + assert.deepEqual(findSurvivors(tokens, [TIER1_DIR, SHARED], "main"), expected); + assert.deepEqual(findSurvivors(tokens, [TIER1_DIR, SHARED]), expected); + assert.deepEqual(findSurvivors([], [TIER1_DIR], "main"), []); + assert.deepEqual(findSurvivors(tokens, [], "main"), []); + assert.throws(() => findSurvivors(tokens, [SHARED], "refs/heads/no-such-branch"), + /cannot read refs\/heads\/no-such-branch/); + assert.deepEqual(attributeRemoval("oldWidgetName", base, tip).length, 1); + assert.match(attributeRemoval("oldWidgetName", base, tip)[0]!, /rename oldWidgetName/); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("the impact plan scopes upstream to what the fork touches", () => { + const { dir, base, ours, tip } = syncRepo(false); + try { + inRepo(dir, () => { + const plan = planSync(base, tip, ours); + assert.deepEqual(plan.commits.map(line => line.replace(/^[0-9a-f]+ /, "")), [ + "upstream: shuffle and add", + "upstream: touch removed workflow", + "upstream: rename oldWidgetName", + ]); + assert.deepEqual(plan.reconcile, [SHARED]); + assert.equal(plan.removedTouched.length, 1); + assert.equal(plan.removedTouched[0]!.path, REMOVED); + assert.match(plan.removedTouched[0]!.commit, /touch removed workflow/); + assert.deepEqual(plan.tier1References, [{ + changedFile: "src/upstreamwidget.ts", + keyword: "upstreamwidget", + referencing: [PROBE], + }]); + assert.deepEqual(findTier1References(["src/ix.ts", "src/shared.ts"], ours), [], + "short and stoplisted stems are not worth the grep"); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("import references attribute unambiguous stems only", () => { + const dir = scratchRepo(); + const run = (...args: string[]) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }).trim(); + const probe = `${TIER1_DIR}amb-probe.ts`; + try { + const file = join(dir, probe); + mkdirSync(dirname(file), { recursive: true }); + // Specifiers need not resolve: matching is textual on the last segment. + writeFileSync(file, `import { dup } from "dupmod";\nimport { solo } from "solomod";\n`); + run("add", "."); + run("commit", "-q", "-m", "base"); + inRepo(dir, () => { + assert.deepEqual( + findTier1References(["a/dupmod.ts", "b/dupmod.ts", "c/solomod.ts"], "HEAD"), + [{ changedFile: "c/solomod.ts", keyword: "solomod", referencing: [probe] }]); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("merge policy keeps removals deleted and stops on Tier 2 and Tier-1 collisions", () => { + const { dir, run } = syncRepo(true); + const clashPath = `${TIER1_DIR}clash.ts`; + try { + try { + run("merge", "--no-commit", "--no-ff", "upstream"); + } catch { /* conflicts stop the merge; that is the fixture working */ } + inRepo(dir, () => { + assert.equal(mergeHeadPresent(), true); + const policy = applyMergePolicy(); + assert.deepEqual(policy.autoResolved, [REMOVED]); + assert.deepEqual( + policy.needsHuman.toSorted((a, b) => a.path < b.path ? -1 : 1), + [ + { path: clashPath, tier: "Tier 1 (collision)" }, + { path: SHARED, tier: "Tier 2" }, + ].toSorted((a, b) => a.path < b.path ? -1 : 1)); + assert.deepEqual(unmergedPaths(), [clashPath, SHARED].toSorted()); + const msg = readFileSync(join(dir, ".git", "MERGE_MSG"), "utf8"); + assert.match(msg, /kept deleted/); + assert.ok(msg.includes(REMOVED)); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("verify context follows in-progress, committed, and preview merges", () => { + const { dir, run, base, tip, ours } = syncRepo(false); + try { + try { + run("merge", "--no-commit", "--no-ff", "upstream"); + } catch { /* conflicts stop the merge; that is the fixture working */ } + inRepo(dir, () => { + assert.throws(() => resolveVerifyContext("upstream"), /resolve the remaining conflicts/); + }); + run("checkout", "--theirs", "--", SHARED); + run("rm", "-q", "--", REMOVED); + run("add", "-A"); + inRepo(dir, () => { + const ctx = resolveVerifyContext("upstream")!; + assert.equal(ctx.mode, "in-progress"); + assert.equal(ctx.grepRef, undefined); + assert.equal(ctx.base, base); + assert.equal(ctx.oursRef, ours); + assert.equal(ctx.upstreamTip, tip); + assert.equal(ctx.auditUpstream, "upstream"); + }); + run("commit", "-q", "--no-edit"); + inRepo(dir, () => { + const ctx = resolveVerifyContext("upstream")!; + assert.equal(ctx.mode, "committed"); + assert.equal(ctx.grepRef, "HEAD"); + assert.equal(ctx.upstreamTip, tip); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("verify context previews against upstream and refuses without one", () => { + const { dir, base } = syncRepo(false); + try { + inRepo(dir, () => { + const ctx = resolveVerifyContext("upstream")!; + assert.equal(ctx.mode, "preview"); + assert.equal(ctx.base, base); + assert.equal(ctx.grepRef, "HEAD"); + assert.equal(resolveVerifyContext("main"), null); + assert.throws(() => resolveVerifyContext(), /no upstream ref/); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("preflight refuses dirty trees, bad refs, and repeats", () => { + const { dir, base } = syncRepo(false); + try { + writeFileSync(join(dir, SHARED), "dirty\n"); + inRepo(dir, () => { + assert.throws(() => preflightStart("upstream"), UsageError); + assert.throws(() => preflightStart("upstream"), /commit or stash/); + }); + execFileSync("git", ["-C", dir, "checkout", "--", SHARED]); + inRepo(dir, () => { + assert.throws(() => preflightStart("refs/heads/nope"), /does not resolve/); + assert.equal(preflightStart("main"), null); + assert.deepEqual(preflightStart("upstream"), { tip: "upstream", base }); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("verification gates pin their commands", () => { + const vp = fileURLToPath(new URL("../vp/run.ts", import.meta.url)); + const audit = fileURLToPath(new URL("./upstream-merge-audit.ts", import.meta.url)); + assert.deepEqual(typecheckPlan("/repo"), [ + { cmd: "pnpm", args: ["types:scripts"], cwd: "/repo" }, + { cmd: process.execPath, + args: [vp, "--filter=!cloudflare-os", "--no-cache", "build"], cwd: "/repo" }, + ]); + assert.deepEqual(auditCommand("/repo", "foundation/main"), { + cmd: process.execPath, args: [audit, "--upstream", "foundation/main"], cwd: "/repo", + }); +}); + +const SYNC_CLI = fileURLToPath(new URL("./sync-upstream.ts", import.meta.url)); + +/** Runs the CLI in `dir`, returning its exit status and captured stdout. */ +function runSyncOut(dir: string, ...args: string[]): { status: number; stdout: string } { + try { + const stdout = execFileSync("node", [SYNC_CLI, ...args], + { cwd: dir, encoding: "utf8", stdio: "pipe" }); + return { status: 0, stdout }; + } catch (error) { + const { status, stdout } = error as { status?: number; stdout?: string }; + return { status: status ?? -1, stdout: stdout ?? "" }; + } +} + +/** Runs the CLI in `dir` and returns its exit status. */ +function runSync(dir: string, ...args: string[]): number { + return runSyncOut(dir, ...args).status; +} + +test("a sync runs end to end: impact, merge, policy, resolve, green verify", () => { + const { dir, run } = syncRepo(false); + try { + assert.equal(runSync(dir, "--upstream", "upstream", "--branch", "sync/test"), 1); + assert.equal(run("branch", "--show-current"), "sync/test"); + assert.ok(readFileSync(join(dir, ".git", "MERGE_MSG"), "utf8").includes(REMOVED)); + // Resolve: upstream's side for Tier 2, and rename the Tier-1 survivor. + run("checkout", "--theirs", "--", SHARED); + const probe = join(dir, PROBE); + writeFileSync(probe, readFileSync(probe, "utf8").replaceAll("oldWidgetName", "newWidgetName")); + run("add", "-A"); + assert.equal(runSync(dir, "--verify", "--upstream", "upstream", "--skip-typecheck"), 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("compound names are multi-segment identifiers, never prose or hex", () => { + for (const token of ["prohibitAllSharing", "AdminConfig", "addModel", "abort_event", "$store", "id2000"]) { + assert.equal(isCompoundName(token), true, token); + } + for (const token of ["Likewise", "Returns", "Catalog", "Unicode", "Called", "e2f1707", "0x1A4f"]) { + assert.equal(isCompoundName(token), false, token); + } + assert.equal(isCompoundName("deadBEEF"), true, "hex letters in a name do not make it a hash"); + // Shape-true (digits), but the tip-count cap drops colors and hashes that survive upstream. + assert.equal(isCompoundName("b84e00"), true); +}); + +test("surviving tokens are compound names nearly gone from the tip", () => { + const dir = scratchRepo(); + const run = (...args: string[]) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }).trim(); + try { + const aliveBase = `export const compoundAlive = [${ + Array(11).fill("compoundAlive").join(", ")}];\n`; + writeFileSync(join(dir, "words.ts"), + "export const compoundGone = 1;\n" + + "export const compoundRare = 2;\n" + + "export const compoundRareUse = compoundRare;\n" + + "// compoundRare configured.\n" + + aliveBase + + "export const solitude = 4;\n"); + run("add", "."); + run("commit", "-q", "-m", "base"); + const base = run("rev-parse", "HEAD"); + run("checkout", "-q", "-b", "upstream"); + writeFileSync(join(dir, "words.ts"), + "// compoundRare noted here, and compoundRare again.\n" + + `// ${Array(10).fill("compoundAlive").join(" ")}\n` + + "export const kept = 5;\n"); + run("commit", "-qam", "upstream removes the names"); + const tip = run("rev-parse", "upstream"); + inRepo(dir, () => { + // compoundAlive is net-removed but still said ten times: alive, not a survivor. + // solitude is gone everywhere but single-word: prose-shaped, the typecheck owns it. + assert.deepEqual(survivingTokens(base, tip), [ + { token: "compoundGone", tipCount: 0 }, + { token: "compoundRareUse", tipCount: 0 }, + { token: "compoundRare", tipCount: 2 }, + ]); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a sync behind upstream previews the pending range instead", () => { + const { dir, run, tip } = syncRepo(false); + try { + try { + run("merge", "--no-commit", "--no-ff", "upstream"); + } catch { /* conflicts stop the merge; that is the fixture working */ } + run("checkout", "--theirs", "--", SHARED); + run("rm", "-q", "--", REMOVED); + run("add", "-A"); + run("commit", "-q", "--no-edit"); + run("checkout", "-q", "upstream"); + run("commit", "-q", "--allow-empty", "-m", "upstream moves on"); + run("checkout", "-q", "main"); + inRepo(dir, () => { + const ctx = resolveVerifyContext("upstream")!; + assert.equal(ctx.mode, "preview"); + assert.equal(ctx.upstreamTip, "upstream"); + assert.equal(ctx.base, tip); + assert.ok(ctx.staleSync?.includes("merge commit")); + }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a stale preview names the sync it supersedes", () => { + const { dir, run } = syncRepo(false); + try { + try { + run("merge", "--no-commit", "--no-ff", "upstream"); + } catch { /* conflicts stop the merge; that is the fixture working */ } + run("checkout", "--theirs", "--", SHARED); + run("rm", "-q", "--", REMOVED); + // Rename the Tier-1 survivor too, so the preview has nothing to report. + const probe = join(dir, PROBE); + writeFileSync(probe, readFileSync(probe, "utf8").replaceAll("oldWidgetName", "newWidgetName")); + run("add", "-A"); + run("commit", "-q", "--no-edit"); + run("checkout", "-q", "upstream"); + run("commit", "-q", "--allow-empty", "-m", "upstream moves on"); + run("checkout", "-q", "main"); + const { status, stdout } = runSyncOut(dir, "--verify", "--upstream", "upstream", "--skip-typecheck"); + assert.equal(status, 0); + assert.match(stdout, /behind upstream; previewing the pending range/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a dry run plans without branching or merging", () => { + const { dir, run } = syncRepo(false); + try { + assert.equal(runSync(dir, "--dry-run", "--upstream", "upstream"), 0); + assert.equal(run("branch", "--show-current"), "main"); + assert.equal(run("branch", "--list", "sync/*"), ""); + inRepo(dir, () => assert.equal(mergeHeadPresent(), false)); + assert.equal(runSync(dir, "--dry-run", "--verify", "--upstream", "upstream"), 2); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a re-run mid-merge reports what is left", () => { + const { dir, run } = syncRepo(false); + try { + assert.equal(runSync(dir, "--upstream", "upstream", "--branch", "sync/test"), 1); + assert.equal(runSync(dir, "--upstream", "upstream", "--branch", "sync/test"), 1, + "re-entry must not re-resolve or fail: it reports the remaining conflicts"); + run("checkout", "--theirs", "--", SHARED); + run("add", "-A"); + assert.equal(runSync(dir, "--upstream", "upstream", "--branch", "sync/test"), 0, + "with nothing left, re-entry points at --verify"); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/fork/sync-upstream.ts b/scripts/fork/sync-upstream.ts new file mode 100644 index 0000000000..40b4b239ad --- /dev/null +++ b/scripts/fork/sync-upstream.ts @@ -0,0 +1,756 @@ +/** + * The fork's merge strategy for upstream syncs: policy-driven orchestration around a plain + * `git merge`, where the policy is the Tier-1 boundary in `fork-boundary.json`. + * + * Two phases, because a sync has a human in the middle of it: + * + * 1. `pnpm fork:sync` -- preflight (fetch, shallow and clean-tree checks), an impact report + * scoped to what the fork touches, then the merge itself with `--no-commit` so even a clean + * merge waits for verification. The only automatic resolutions are the no-judgment ones: + * upstream touches to deliberately-removed files are kept deleted, recorded in the commit + * message. Everything else that conflicts stops for hand resolution -- Tier 2 by rule, Tier-1 + * conflicts as boundary contradictions. + * 2. `pnpm fork:sync --verify` -- after resolving: upstream-removed names the fork still uses, + * an uncached typecheck (a sync breaks packages it never touches, which the task cache would + * replay as passing), and the merge audit. Exit 0 means commit; anything else names its stage. + * + * `--verify` also runs before any merge as a preview: same checks against HEAD, minus the audit, + * which has no merge to look at yet. `--dry-run` prints the impact report with no branch and no + * merge, for scoping a sync before starting one. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { forkBoundary } from "./fork-boundary.ts"; +import { + authoritativeUpstreamRef, + isForkOwned, + isShallowRepository, + locateMerge, + UsageError, +} from "./upstream-merge-audit.ts"; + +const SYNC_DIR = dirname(fileURLToPath(import.meta.url)); + +function git(args: string[]): string { + return execFileSync("git", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); +} + +function gitOrNull(args: string[]): string | null { + try { + return execFileSync("git", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return null; + } +} + +function shortRef(ref: string): string { + return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 12) : ref; +} + +/** True while a merge is stopped -- conflicts, or a clean `--no-commit` stop. */ +export function mergeHeadPresent(): boolean { + return (gitOrNull(["rev-parse", "MERGE_HEAD"])?.trim() || null) !== null; +} + +/** Paths with unresolved conflicts (content merges and add/adds, both delete directions). */ +export function unmergedPaths(): string[] { + return git(["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean).toSorted(); +} + +/** + * Fetch-upstream, shallow-clone, and clean-tree gates for starting a sync. Returns the tip and + * merge base, or null when there is nothing to sync. An explicit `--upstream` skips the fetch. + */ +export function preflightStart(explicitUpstream?: string): { tip: string; base: string } | null { + let tip: string; + if (explicitUpstream !== undefined) { + tip = authoritativeUpstreamRef(explicitUpstream) ?? explicitUpstream; + } else { + try { + git(["fetch", "foundation", "main"]); + } catch { + throw new UsageError("cannot fetch foundation main. Fetch it manually, or pass --upstream ."); + } + const ref = authoritativeUpstreamRef(undefined); + if (ref === null) { + throw new UsageError("foundation/main is unavailable even after fetching; pass --upstream ."); + } + tip = ref; + } + if (isShallowRepository()) { + throw new UsageError("this clone is shallow, so merge-base cannot be trusted. Unshallow the " + + "remote the graft is on:\n git fetch --unshallow foundation main"); + } + try { + git(["diff", "--quiet"]); + git(["diff", "--cached", "--quiet"]); + } catch { + throw new UsageError("commit or stash your changes first; the worktree and index must be clean."); + } + const base = git(["merge-base", "HEAD", tip]).trim(); + if (base === git(["rev-parse", tip]).trim()) return null; + return { tip, base }; +} + +/** Fork-side changes split by what the sync must do with them. */ +export interface ForkTouched { + /** Fork-modified upstream files: Tier 2, always resolved by hand. */ + tier2: string[]; + /** Fork-added files outside every Tier-1 prefix: move them or declare the prefix. */ + undeclaredAdds: string[]; + /** Fork-deleted upstream files with no recorded removal: record or restore. */ + unrecordedDeletions: string[]; +} + +export function forkTouchedFiles(base: string, ours: string, tip: string): ForkTouched { + const changed = git(["diff", "--name-only", base, ours]).split("\n").filter(Boolean); + const added = new Set( + git(["diff", "--name-only", "--diff-filter=A", base, ours]).split("\n").filter(Boolean)); + const deleted = new Set( + git(["diff", "--name-only", "--diff-filter=D", base, ours]).split("\n").filter(Boolean)); + // A fork-added path upstream also added is an add/add waiting to happen, not an undeclared + // add -- it needs hand resolution like any other Tier-2 file. + const addedUpstream = new Set(added.size > 0 + ? git(["ls-tree", "-r", "--name-only", tip, "--", ...added]).split("\n").filter(Boolean) + : []); + const removed = forkBoundary().removedUpstreamPaths; + const tier2: string[] = []; + const undeclaredAdds: string[] = []; + const unrecordedDeletions: string[] = []; + for (const path of changed.toSorted()) { + if (isForkOwned(path)) continue; + if (removed[path] !== undefined) continue; + if (deleted.has(path)) { + unrecordedDeletions.push(path); + continue; + } + if (added.has(path) && !addedUpstream.has(path)) { + undeclaredAdds.push(path); + continue; + } + tier2.push(path); + } + return { tier2, undeclaredAdds, unrecordedDeletions }; +} + +export function upstreamChangedFiles(base: string, tip: string): string[] { + return git(["diff", "--name-only", base, tip]).split("\n").filter(Boolean).toSorted(); +} + +export function upstreamCommits(base: string, tip: string): string[] { + return git(["log", "--format=%h %s", `${base}..${tip}`]).split("\n").filter(Boolean); +} + +/** An upstream touch to a deliberately-removed file, with the commit that made it. */ +export interface RemovedTouch { + path: string; + commit: string; +} + +/** + * An upstream-changed module a Tier-1 file imports. Textual matching on the specifier's last + * segment, not module resolution -- a heuristic for eyeballing, not gating. + */ +export interface Tier1Reference { + changedFile: string; + keyword: string; + referencing: string[]; +} + +export interface SyncPlan { + commits: string[]; + upstreamChanged: string[]; + tier2: string[]; + undeclaredAdds: string[]; + unrecordedDeletions: string[]; + /** Changed on both sides: expect conflicts or silent auto-merges, and review each. */ + reconcile: string[]; + removedTouched: RemovedTouch[]; + tier1References: Tier1Reference[]; +} + +const REFERENCE_STOPLIST = new Set(["shared", "common", "config", "helper", "helpers"]); + +function stemOf(path: string): string | null { + const file = path.split("/").pop() ?? ""; + if (/\.test\.[jt]sx?$/.test(file) || /\.config\.[jt]s$/.test(file)) return null; + const stem = file.replace(/\.d\.ts$/, "").replace(/\.[^.]*$/, ""); + if (stem.length < 6 || REFERENCE_STOPLIST.has(stem)) return null; + return stem; +} + +export function findTier1References(upstreamChanged: string[], ours: string): Tier1Reference[] { + // Stems of changed files. A stem shared by several changed files cannot be attributed to one + // by text alone, so only unambiguous stems report. + const changedByStem = new Map(); + for (const file of upstreamChanged) { + const stem = stemOf(file); + if (!stem) continue; + const files = changedByStem.get(stem) ?? []; + files.push(file); + changedByStem.set(stem, files); + } + const prefixes = forkBoundary().forkOwned.map(entry => entry.path); + const out = gitOrNull(["grep", "-o", "-E", + "-e", `from ["'][^"']+["']`, + "-e", `import\\(["'][^"']+["']\\)`, + "-e", `require\\(["'][^"']+["']\\)`, + "-e", `import ["'][^"']+["']`, + ours, "--", ...prefixes]); + if (out === null) return []; + const referencingByStem = new Map>(); + for (const line of out.split("\n").filter(Boolean)) { + // `::`; the match holds one quoted specifier. + const file = line.slice(ours.length + 1, line.lastIndexOf(":")); + const spec = /["']([^"']+)["']/.exec(line.slice(line.lastIndexOf(":") + 1))?.[1]; + const stem = spec ? stemOf(spec) : null; + if (!stem) continue; + const referencing = referencingByStem.get(stem) ?? new Set(); + referencing.add(file); + referencingByStem.set(stem, referencing); + } + const references: Tier1Reference[] = []; + for (const [stem, files] of [...changedByStem.entries()].toSorted((a, b) => a[0] < b[0] ? -1 : 1)) { + if (files.length !== 1) continue; + const referencing = referencingByStem.get(stem); + if (!referencing) continue; + references.push({ changedFile: files[0], keyword: stem, referencing: [...referencing].toSorted() }); + } + return references; +} + +export function planSync(base: string, tip: string, ours: string): SyncPlan { + const commits = upstreamCommits(base, tip); + const upstreamChanged = upstreamChangedFiles(base, tip); + const upstreamSet = new Set(upstreamChanged); + const touched = forkTouchedFiles(base, ours, tip); + const reconcile = touched.tier2.filter(path => upstreamSet.has(path)); + const removed = forkBoundary().removedUpstreamPaths; + const removedTouched: RemovedTouch[] = []; + for (const path of upstreamChanged) { + if (removed[path] === undefined) continue; + const commit = gitOrNull(["log", "--format=%h %s", "-1", `${base}..${tip}`, "--", path]) + ?.trim() || "(unattributed)"; + removedTouched.push({ path, commit }); + } + return { commits, upstreamChanged, ...touched, reconcile, removedTouched, + tier1References: findTier1References(upstreamChanged, ours) }; +} + +export function printImpactReport(plan: SyncPlan, base: string, tip: string): void { + console.log(`Upstream ${shortRef(base)}..${shortRef(tip)}: ${plan.commits.length} commits`); + for (const commit of plan.commits) console.log(` ${commit}`); + console.log(""); + if (plan.reconcile.length > 0) { + console.log("Changed on both sides -- review each (conflict or silent auto-merge):"); + for (const path of plan.reconcile) console.log(` ${path}`); + console.log(""); + } + if (plan.removedTouched.length > 0) { + console.log("Upstream touched deliberately-removed files (auto-kept deleted):"); + for (const { path, commit } of plan.removedTouched) console.log(` ${path} (${commit})`); + console.log(""); + } + if (plan.tier1References.length > 0) { + console.log("Upstream changed modules Tier-1 files import (heuristic -- eyeball it):"); + for (const { changedFile, keyword, referencing } of plan.tier1References) { + console.log(` ${changedFile} (keyword \`${keyword}\`) referenced by:`); + for (const path of referencing) console.log(` ${path}`); + } + console.log(""); + } + if (plan.undeclaredAdds.length > 0) { + console.log("Fork-added files outside Tier 1 (move them or declare the prefix):"); + for (const path of plan.undeclaredAdds) console.log(` ${path}`); + console.log(""); + } + if (plan.unrecordedDeletions.length > 0) { + console.log("Fork-deleted upstream files with no recorded removal (record or restore):"); + for (const path of plan.unrecordedDeletions) console.log(` ${path}`); + console.log(""); + } + console.log(`${plan.tier2.length - plan.reconcile.length} Tier-2 files untouched upstream merge clean.`); + console.log("Predicted review surface, not conflicts: intersections only. Starting the merge."); +} + +export function createSyncBranch(branch: string): void { + if (gitOrNull(["rev-parse", "--verify", "--quiet", `${branch}^{commit}`]) !== null) { + throw new UsageError(`branch ${branch} already exists; delete it or pass --branch `); + } + git(["checkout", "-b", branch]); +} + +export function runMerge(tip: string): void { + try { + git(["merge", "--no-commit", "--no-ff", tip]); + } catch (error) { + if (!mergeHeadPresent()) { + const detail = ((error as { stderr?: unknown }).stderr as string | undefined)?.trim(); + throw new Error(`the merge failed without starting${detail ? `: ${detail.slice(0, 500)}` : ""}`, + { cause: error }); + } + // MERGE_HEAD present: conflicts to resolve, or a clean --no-commit stop. + } +} + +/** What the merge policy did with each unmerged path. */ +export interface MergePolicyResult { + /** Deliberately-removed files, kept deleted. Recorded in the commit message. */ + autoResolved: string[]; + /** Everything else: hand resolution, with the tier that says why. */ + needsHuman: Array<{ path: string; tier: "Tier 1 (collision)" | "Tier 2" }>; +} + +/** + * Applies the only automatic resolutions the strategy allows: upstream touches to + * deliberately-removed files are kept deleted, since the removal is a recorded decision with no + * reconciled form. A Tier-1 path here contradicts the boundary claim -- it stops, loudly. + */ +export function applyMergePolicy(): MergePolicyResult { + const removed = forkBoundary().removedUpstreamPaths; + const autoResolved: string[] = []; + const needsHuman: MergePolicyResult["needsHuman"] = []; + for (const path of unmergedPaths()) { + if (removed[path] !== undefined) { + git(["rm", "-q", "--", path]); + autoResolved.push(path); + } else { + needsHuman.push({ path, tier: isForkOwned(path) ? "Tier 1 (collision)" : "Tier 2" }); + } + } + if (autoResolved.length > 0) recordPolicyResolutions(autoResolved); + return { autoResolved, needsHuman }; +} + +function recordPolicyResolutions(autoResolved: string[]): void { + const lines = ["", "Auto-resolved by fork:sync policy (deliberate removals, kept deleted):", + ...autoResolved.map(path => `- ${path} (see removedUpstreamPaths in fork-boundary.json)`)]; + const msgPath = git(["rev-parse", "--git-path", "MERGE_MSG"]).trim(); + if (!existsSync(msgPath)) { + console.log(lines.join("\n")); + return; + } + appendFileSync(msgPath, `${lines.join("\n")}\n`); +} + +export function printPolicyReport(result: MergePolicyResult, reentry: boolean): void { + for (const path of result.autoResolved) { + console.log(`Auto-resolved (policy: deliberate removal): ${path} -- kept deleted.`); + } + if (result.autoResolved.length > 0) { + console.log("Upstream's side of each is in the merge; the resolutions are noted in the message."); + } + if (result.needsHuman.length === 0) { + console.log(reentry + ? "No conflicts remain. Run: pnpm fork:sync --verify" + : "Merged clean (uncommitted). Run: pnpm fork:sync --verify"); + return; + } + console.log("Resolve by hand (Tier 2: start from upstream's version, re-apply ours):"); + for (const { path, tier } of result.needsHuman) console.log(` [${tier}] ${path}`); + if (result.needsHuman.some(entry => entry.tier !== "Tier 2")) { + console.log("Tier-1 conflicts contradict the boundary claim: resolve, then drop the entry " + + "from fork-boundary.json."); + } + console.log("Compare sides with: git diff HEAD...MERGE_HEAD -- (upstream's side)"); + console.log("Then run: pnpm fork:sync --verify"); +} + +const IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/g; + +/** + * Identifier-shaped tokens upstream removed more often than it added across the sync range -- + * renames, deleted flags, dropped APIs. Net removal, not gross: a token that moved within a file + * is equally added and removed, and a token the fork legitimately mirrors still exists upstream + * beside its new uses. Short tokens are refactor churn (`id`, `data`), so they stay out. + */ +export function removedIdentifiers(base: string, tip: string): string[] { + const diff = git(["diff", "--word-diff=porcelain", base, tip]); + const removed = new Map(); + const added = new Map(); + for (const line of diff.split("\n")) { + const marker = line[0]; + if (marker !== "+" && marker !== "-") continue; + const table = marker === "-" ? removed : added; + for (const token of line.slice(1).match(IDENTIFIER) ?? []) { + if (token.length < 6) continue; + table.set(token, (table.get(token) ?? 0) + 1); + } + } + return [...removed.entries()] + .filter(([token, count]) => count > (added.get(token) ?? 0)) + .map(([token]) => token).toSorted(); +} + +/** An upstream-removed name the fork's files still use. */ +export interface SymbolSurvivor { + token: string; + files: string[]; +} + +/** + * Greps the merged state for removed identifiers, scoped to fork files: Tier 1 plus the sync's + * Tier 2. A survivor in an untouched upstream file would be upstream's own inconsistency, not a + * fork problem, so those files are out of scope. `ref` reads a commit; without one, the worktree + * (uncommitted resolutions). + */ +export function findSurvivors(tokens: string[], paths: string[], ref?: string): SymbolSurvivor[] { + if (tokens.length === 0 || paths.length === 0) return []; + const args = ["grep", "-o", "-w", "-F", ...tokens.flatMap(token => ["-e", token])]; + if (ref !== undefined) args.push(ref); + args.push("--", ...paths); + const out = gitOrNull(args); + if (out === null) { + if (ref !== undefined && gitOrNull(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null) { + throw new Error(`cannot read ${ref}: history may be incomplete`); + } + return []; + } + const byToken = new Map>(); + for (const line of out.split("\n").filter(Boolean)) { + // `:`, or `::` in ref mode. + const file = ref === undefined ? line.slice(0, line.lastIndexOf(":")) : line.slice(ref.length + 1, line.lastIndexOf(":")); + const token = line.slice(line.lastIndexOf(":") + 1); + const files = byToken.get(token) ?? new Set(); + files.add(file); + byToken.set(token, files); + } + return [...byToken.entries()] + .map(([token, files]) => ({ token, files: [...files].toSorted() })) + .toSorted((a, b) => a.token < b.token ? -1 : 1); +} + +/** + * Multi-segment names (`prohibitAllSharing`, `abort_event`): the survivors check's target. A + * removed single word (`Likewise`, `Returns`) is prose churn, and typed uses of a renamed single + * word already fail the typecheck gate -- so single words never report here. Pure hex is a hash + * or color, not a name. + */ +export function isCompoundName(token: string): boolean { + if (/^[0-9a-f]{7,}$/.test(token) || /^0x[0-9a-f]+$/i.test(token)) return false; + return /[A-Z]/.test(token.slice(1)) || /[_$0-9]/.test(token); +} + +/** + * How often a net-removed name may still occur at the tip before it reads as alive rather than + * removed-with-leftovers. Calibrated on a real sync: `prohibitAllSharing` survives 3 times (a + * `storageKey` string plus two doc mentions) while dead, and `SELF_CLOSING_HTML` twice (docs + * only), while the still-exported `AccountCredentialStub` and `McpServerInfo` occur 4 times. + */ +export const MAX_TIP_OCCURRENCES = 3; + +/** Every identifier-shaped token at `ref`, counted. One full-tree scan, ~200ms. */ +export function tipTokenCounts(tip: string): Map { + const out = gitOrNull(["grep", "-o", "-h", "-E", "-e", "[A-Za-z_$][A-Za-z0-9_$]*", tip]); + if (out === null) { + if (gitOrNull(["rev-parse", "--verify", "--quiet", `${tip}^{commit}`]) === null) { + throw new Error(`cannot read ${tip}: history may be incomplete`); + } + return new Map(); + } + const counts = new Map(); + for (const line of out.split("\n")) { + if (!line) continue; + counts.set(line, (counts.get(line) ?? 0) + 1); + } + return counts; +} + +/** A removed name and how often the tip still says it. */ +export interface ScoredToken { + token: string; + tipCount: number; +} + +/** + * Removed identifiers worth grepping the fork for: compound names occurring at most a handful + * of times at the tip -- gone, or dead with doc-string leftovers. Sorted by count, so definite + * deletions come first. + */ +export function survivingTokens(base: string, tip: string): ScoredToken[] { + const counts = tipTokenCounts(tip); + return removedIdentifiers(base, tip) + .filter(token => isCompoundName(token) && (counts.get(token) ?? 0) <= MAX_TIP_OCCURRENCES) + .map(token => ({ token, tipCount: counts.get(token) ?? 0 })) + .toSorted((a, b) => a.tipCount - b.tipCount || (a.token < b.token ? -1 : 1)); +} + +/** The upstream commits that changed an identifier's occurrence count, most recent first. */ +export function attributeRemoval(token: string, base: string, tip: string): string[] { + return (gitOrNull(["log", "--format=%h %s", `-S${token}`, `${base}..${tip}`]) ?? "") + .split("\n").filter(Boolean); +} + +export interface VerifyContext { + base: string; + oursRef: string; + upstreamTip: string; + /** What the spawned audit compares against: explicit `--upstream`, else the merge's parent. */ + auditUpstream: string; + /** The tree to grep: undefined reads the worktree (uncommitted resolutions). */ + grepRef: string | undefined; + mode: "in-progress" | "committed" | "preview"; + /** When a preview supersedes an old sync, the stale sync it replaces. */ + staleSync?: string; +} + +/** + * Resolves what `--verify` checks: an in-progress merge (worktree), a committed sync (HEAD), or + * -- with no merge anywhere -- a pre-sync preview of HEAD against upstream. Null when there is + * nothing pending. + */ +export function resolveVerifyContext(explicitUpstream?: string): VerifyContext | null { + const authoritative = authoritativeUpstreamRef(explicitUpstream); + const mergeHead = gitOrNull(["rev-parse", "MERGE_HEAD"])?.trim() || null; + if (mergeHead) { + if (/\s/.test(mergeHead)) { + throw new UsageError("Cannot verify an in-progress octopus merge: more than two parents " + + "are unsupported."); + } + const remaining = unmergedPaths(); + if (remaining.length > 0) { + throw new UsageError("resolve the remaining conflicts first:\n" + + remaining.map(path => ` ${path}`).join("\n")); + } + return { + base: git(["merge-base", "HEAD", mergeHead]).trim(), + oursRef: git(["rev-parse", "HEAD"]).trim(), + upstreamTip: mergeHead, + auditUpstream: authoritative ?? mergeHead, + grepRef: undefined, + mode: "in-progress", + }; + } + const merge = locateMerge(undefined, "HEAD", authoritative ?? undefined); + if (merge) { + if (authoritative) { + const current = git(["rev-parse", authoritative]).trim(); + if (current !== git(["rev-parse", merge.upstreamRef]).trim()) { + // The located sync is behind upstream: HEAD has unmerged upstream commits, so the + // pending range is what needs verifying, not the old sync. + const pendingBase = git(["merge-base", "HEAD", authoritative]).trim(); + if (pendingBase === current) return null; + return { + base: pendingBase, + oursRef: git(["rev-parse", "HEAD"]).trim(), + upstreamTip: authoritative, + auditUpstream: authoritative, + grepRef: "HEAD", + mode: "preview", + staleSync: merge.description, + }; + } + } + return { + base: merge.baseRef, + oursRef: merge.oursRef, + upstreamTip: merge.upstreamRef, + auditUpstream: authoritative ?? merge.upstreamRef, + grepRef: "HEAD", + mode: "committed", + }; + } + if (!authoritative) { + throw new UsageError("no merge in progress or in ancestry, and no upstream ref. Fetch first:\n" + + " git fetch foundation main (or pass --upstream )"); + } + const base = git(["merge-base", "HEAD", authoritative]).trim(); + if (base === git(["rev-parse", authoritative]).trim()) return null; + return { + base, + oursRef: git(["rev-parse", "HEAD"]).trim(), + upstreamTip: authoritative, + auditUpstream: authoritative, + grepRef: "HEAD", + mode: "preview", + }; +} + +export function printSurvivorReport( + survivors: SymbolSurvivor[], counts: Map, base: string, tip: string, +): void { + if (survivors.length === 0) { + console.log("No upstream-removed names survive in fork files."); + return; + } + console.log(`Upstream-removed names your files still use (${survivors.length}):`); + for (const { token, files } of survivors.slice(0, 25)) { + const left = counts.get(token) ?? 0; + console.log(` ${token} (${left === 0 ? "gone upstream" : `${left} left upstream`})`); + for (const commit of attributeRemoval(token, base, tip).slice(0, 2)) { + console.log(` upstream: ${commit}`); + } + for (const file of files) console.log(` yours: ${file}`); + } + if (survivors.length > 25) console.log(` ...and ${survivors.length - 25} more tokens.`); +} + +/** + * A verification gate. Tool paths resolve from this file -- the tooling travels together -- + * while `cwd` is the repository under sync, which may be any checkout. + */ +export interface GateCommand { + cmd: string; + args: string[]; + cwd: string; +} + +export function repoRoot(): string { + return git(["rev-parse", "--show-toplevel"]).trim(); +} + +/** + * The uncached typecheck: every package's `tsc`, plus the repo scripts. Uncached because a sync + * breaks packages it never touches, whose recorded passes would otherwise replay. + */ +export function typecheckPlan(cwd: string): GateCommand[] { + return [ + { cmd: "pnpm", args: ["types:scripts"], cwd }, + { cmd: process.execPath, + args: [join(SYNC_DIR, "..", "vp", "run.ts"), "--filter=!cloudflare-os", "--no-cache", "build"], + cwd }, + ]; +} + +export function auditCommand(cwd: string, upstream: string): GateCommand { + return { cmd: process.execPath, + args: [join(SYNC_DIR, "upstream-merge-audit.ts"), "--upstream", upstream], + cwd }; +} + +export function runGate(command: GateCommand): number { + const result = spawnSync(command.cmd, command.args, { cwd: command.cwd, stdio: "inherit" }); + if (result.error) { + throw new Error(`could not run ${command.cmd} ${command.args.join(" ")}: ` + + `${(result.error as Error).message}`); + } + return result.status ?? 1; +} + +function start(explicitUpstream: string | undefined, branchOpt: string | undefined): number { + if (mergeHeadPresent()) { + const policy = applyMergePolicy(); + printPolicyReport(policy, true); + return policy.needsHuman.length > 0 ? 1 : 0; + } + const ready = preflightStart(explicitUpstream); + if (!ready) { + console.log("Already in sync with upstream; nothing to do."); + return 0; + } + const ours = git(["rev-parse", "HEAD"]).trim(); + printImpactReport(planSync(ready.base, ready.tip, ours), ready.base, ready.tip); + createSyncBranch(branchOpt ?? `sync/foundation-${new Date().toISOString().slice(0, 10)}`); + runMerge(ready.tip); + const policy = applyMergePolicy(); + printPolicyReport(policy, false); + return policy.needsHuman.length > 0 ? 1 : 0; +} + +function verify(explicitUpstream: string | undefined, skipTypecheck: boolean): number { + const ctx = resolveVerifyContext(explicitUpstream); + if (!ctx) { + console.log("Already in sync with upstream; nothing to do."); + return 0; + } + if (ctx.mode === "preview") { + console.log(ctx.staleSync + ? `Note: the last sync (${ctx.staleSync}) is behind upstream; previewing the pending range.\n` + : "Preview: no merge yet, checking HEAD against upstream.\n"); + } + const touched = forkTouchedFiles(ctx.base, ctx.oursRef, ctx.upstreamTip); + const scope = [...forkBoundary().forkOwned.map(entry => entry.path), ...touched.tier2]; + const scored = survivingTokens(ctx.base, ctx.upstreamTip); + const counts = new Map(); + for (const { token, tipCount } of scored) counts.set(token, tipCount); + const survivors = findSurvivors([...counts.keys()], scope, ctx.grepRef) + .toSorted((a, b) => (counts.get(a.token) ?? 0) - (counts.get(b.token) ?? 0) || + (a.token < b.token ? -1 : 1)); + printSurvivorReport(survivors, counts, ctx.base, ctx.upstreamTip); + let failed = survivors.length > 0; + const root = repoRoot(); + if (!skipTypecheck) { + for (const command of typecheckPlan(root)) { + if (runGate(command) !== 0) failed = true; + } + } + let untrusted = false; + if (ctx.mode !== "preview") { + const status = runGate(auditCommand(root, ctx.auditUpstream)); + if (status === 1) failed = true; + else if (status !== 0) untrusted = true; + } else { + console.log("Skipping the merge audit: no merge exists yet (preview mode)."); + } + if (untrusted) { + console.log("Verify could not be trusted; fix the warnings above and re-run."); + return 2; + } + if (failed) { + console.log("Verify found findings; address them and re-run."); + return 1; + } + console.log(ctx.mode === "in-progress" + ? "Verify clean. Commit the merge when ready (policy notes are pre-filled in the message)." + : "Verify clean."); + return 0; +} + +function dryRun(explicitUpstream: string | undefined): number { + if (mergeHeadPresent()) { + throw new UsageError("a merge is already in progress; --dry-run plans a new one."); + } + const ready = preflightStart(explicitUpstream); + if (!ready) { + console.log("Already in sync with upstream; nothing to do."); + return 0; + } + const ours = git(["rev-parse", "HEAD"]).trim(); + printImpactReport(planSync(ready.base, ready.tip, ours), ready.base, ready.tip); + console.log("Dry run: no branch created, no merge started."); + return 0; +} + +function main(argv: string[]): number { + const value = (name: string): string | undefined => { + const i = argv.indexOf(name); + if (i < 0) return undefined; + const found = argv[i + 1]; + if (found === undefined || found.startsWith("--")) { + throw new UsageError(`${name} needs a value.`); + } + return found; + }; + const explicitUpstream = value("--upstream"); + if (argv.includes("--verify") && argv.includes("--dry-run")) { + throw new UsageError("--verify and --dry-run do not combine."); + } + if (argv.includes("--verify")) { + return verify(explicitUpstream, argv.includes("--skip-typecheck")); + } + if (argv.includes("--dry-run")) { + return dryRun(explicitUpstream); + } + if (argv.includes("--skip-typecheck")) { + throw new UsageError("--skip-typecheck only applies to --verify."); + } + return start(explicitUpstream, value("--branch")); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof UsageError + ? `fork:sync: ${error.message}` + : `fork:sync: ${(error as Error).message}`); + process.exitCode = 2; + } +} diff --git a/scripts/fork/upstream-merge-audit.test.ts b/scripts/fork/upstream-merge-audit.test.ts index b1db65b0a7..4f16882de7 100644 --- a/scripts/fork/upstream-merge-audit.test.ts +++ b/scripts/fork/upstream-merge-audit.test.ts @@ -8,18 +8,17 @@ import { test } from "node:test"; import { ancestry, auditFormatDrift, + auditOwnedPrefixCollisions, auditRemovedPaths, authoritativeUpstreamRef, - FORK_OWNED_PREFIXES, - FORMAT_EXCEPTIONS, isForkOwned, isShallowRepository, isSourceFile, locateMerge, normalizeForFormatComparison, - REMOVED_UPSTREAM_PATHS, UsageError, } from "./upstream-merge-audit.ts"; +import { forkBoundary } from "./fork-boundary.ts"; test("reflow is normalised away, so a reformat reads as no change", () => { const upstreamStyle = `export function f( @@ -57,15 +56,6 @@ test("fork-owned trees are exempt, upstream-owned files are not", () => { assert.equal(isForkOwned("packages/workshop-backend/src/overseer.ts"), false); }); -test("every declared fork-owned prefix is a path prefix, not a bare name", () => { - for (const prefix of FORK_OWNED_PREFIXES) { - assert.ok( - prefix.includes("/"), - `${prefix} must name a directory or file path so it cannot match unrelated packages`, - ); - } -}); - test("only files the normaliser can read are format-checked", () => { for (const path of ["src/a.ts", "src/a.tsx", "b.mjs", "c.js"]) { assert.equal(isSourceFile(path), true, path); @@ -96,16 +86,6 @@ test("an explicit --merge that is not a merge is an error", () => { } }); -test("every deliberately-removed upstream path records why", () => { - const entries = Object.entries(REMOVED_UPSTREAM_PATHS); - assert.ok(entries.length > 0, "the list should not be silently emptied"); - for (const [path, reason] of entries) { - assert.ok(path.includes("/"), `${path} must be a repository path`); - // The reason is the whole point: a bare list rots into "why is this here?" within a sync or two. - assert.ok(reason.length > 30, `${path} needs a reason someone can act on, got: ${reason}`); - } -}); - test("a merge of something that is not upstream is not treated as a sync", () => { // The bug this guards: this repo merges its own PRs with merge commits, so "HEAD is a merge" was // enough to make the audit cast our own branch as upstream and report on it. @@ -218,7 +198,7 @@ for (const variation of ["upstream only", "fork reflow", "fork comment", "unavai for (const variation of ["exact pair", "changed fork", "changed upstream", "unrelated path"] as const) { test(`comment-only formatting exception: ${variation}`, () => { - const exception = FORMAT_EXCEPTIONS[0]!; + const exception = forkBoundary().formatExceptions[0]!; // Ordinary test jobs have shallow history. Reconstruct the reviewed comment-only // delta from checked-in content, verifying both blob IDs before exercising Git trees. const fork = readFileSync(new URL(`../../${exception.path}`, import.meta.url), "utf8"); @@ -439,7 +419,7 @@ test("surviving fork trees are explicitly fork owned", () => { "scripts/fork/connect-initiator-enforced.test.ts", ]) { assert.ok( - FORK_OWNED_PREFIXES.some((prefix) => path.startsWith(prefix)), + forkBoundary().forkOwned.some((entry) => path.startsWith(entry.path)), path, ); } @@ -450,6 +430,36 @@ test("surviving fork trees are explicitly fork owned", () => { assert.equal(isForkOwned("packages/gatekeeper-github/src/github.ts"), false); }); +test("a Tier-1 prefix present upstream is a collision", () => { + // The check reads the live boundary, so the fixture plants the first live Tier-1 prefix + // rather than hardcoding a path -- it follows config edits instead of breaking on them. + const { path: prefix } = forkBoundary().forkOwned[0]!; + const planted = prefix.endsWith("/") ? `${prefix}probe.ts` : prefix; + const dir = scratchRepo(); + const run = (...args: string[]) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: "pipe" }).trim(); + try { + const file = join(dir, planted); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "export const probe = 1;\n"); + run("add", planted); + run("commit", "-q", "-m", "upstream grows into Tier 1"); + run("branch", "-f", "upstream", "HEAD"); + inRepo(dir, () => { + assert.deepEqual(auditOwnedPrefixCollisions("upstream"), [{ prefix, paths: [planted] }]); + }); + assert.equal(runAudit(dir, "--upstream", "upstream"), 1, + "the CLI must report the collision, not pass after skipping it"); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("no collision when upstream lacks every Tier-1 path", () => { + const dir = scratchRepo(); + try { + inRepo(dir, () => assert.deepEqual(auditOwnedPrefixCollisions("upstream"), [])); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + /** A real sync whose ours-only resolution silently drops a clean upstream edit. */ function droppedSyncRepo() { const dir = scratchRepo(); diff --git a/scripts/fork/upstream-merge-audit.ts b/scripts/fork/upstream-merge-audit.ts index 0fa49ba8ac..5b6395b74c 100644 --- a/scripts/fork/upstream-merge-audit.ts +++ b/scripts/fork/upstream-merge-audit.ts @@ -18,12 +18,20 @@ * index) or already committed (resolutions in the merge commit) -- so it runs during a sync, on the * PR that carries it, and on any past merge by ref. Check 2 needs no merge at all and runs on every * branch, which is the point: reflow arrives through ordinary PRs, not through syncs. + * + * Check 3 verifies the Tier-1 premise itself: every fork-owned prefix must be absent upstream. A + * collision is not a conflict to resolve -- it means the boundary entry is wrong, and the path is + * Tier 2 (fork-modified upstream content, resolved by hand) until the config drops it. Like check + * 2 it needs no merge and runs everywhere, because either side can create one through an + * ordinary PR. */ import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { forkBoundary } from "./fork-boundary.ts"; +import type { FormatException } from "./fork-boundary.ts"; /** A file whose upstream changes went missing without a conflict being raised. */ export interface DroppedFile { @@ -39,21 +47,11 @@ export interface FormatChurnFile { rawChurn: number; } -/** One reviewed comment-only divergence; changing either blob requires a fresh review. */ -export interface FormatException { - path: string; - upstreamBlob: string; - forkBlob: string; - reason: string; -} - -/** Exact content pairs exempt only from the formatting check, never from merge auditing. */ -export const FORMAT_EXCEPTIONS: readonly FormatException[] = [{ - path: "packages/workshop-backend/src/worktree-binding.d.ts", - upstreamBlob: "ff54d738edfe0880bf56120e091818c891a7bfb1", - forkBlob: "e7046837ca08f49c6dc657142e9402b2029fceb1", - reason: "Document the enforced full 40-hex commit capability boundary for Worktree.diff().", -}]; +/** + * The Tier-1 boundary (fork-owned prefixes, removed upstream paths, format exceptions) lives in + * `fork-boundary.json`, read through `forkBoundary()`. It is a config rather than consts here so + * the sync script enforces the same boundary the audit checks. + */ /** The merge being audited: who merged what, and where to read the resolved content from. */ export interface MergeUnderAudit { @@ -71,59 +69,6 @@ export interface MergeUnderAudit { classification: "sync" | "unverified"; } -/** - * Paths the fork owns outright. Upstream has no file here, so nothing in these trees can ever - * conflict -- which is exactly why new work belongs in them. Keep in sync with - * `docs/fork-maintenance.md`. - */ -export const FORK_OWNED_PREFIXES = [ - "packages/gatekeeper-kit/__tests__/workerd/credential-mutation.test.ts", - "patches/capnweb-validate@0.3.0.patch", - "scripts/fork/capnweb-native-validation.test.ts", - "packages/workshop-backend/src/fork/", - "packages/workshop-backend/__integration__/knitli-admin-gatekeeper-frame.test.ts", - "packages/workshop-frontend/src/features/admin/gatekeeper-apps/", - "packages/workshop-backend/__tests__/knitli-approval-continuation.test.ts", - "packages/mcp-shared/__tests__/fork/", - "packages/backend-utils/src/access.ts", - "packages/backend-utils/src/fork/", - "packages/gatekeeper-github/__tests__/workerd/knitli-connect-initiator.test.ts", - "packages/gatekeeper-linear/__tests__/workerd/knitli-connect-initiator.test.ts", - "packages/gatekeeper-cloudflare/__tests__/workerd/knitli-connect-initiator.test.ts", - "packages/workshop-backend/wrangler.jsonc", - "packages/gatekeeper-context/wrangler.jsonc", - "packages/gatekeeper-ai-executor/", - "packages/integration-tests/__tests__/fork/", - "scripts/fork/", - "packages/router/wrangler.jsonc", - "wrangler.jsonc", - ".github/workflows/fork-audit.yml", - "docs/fork-maintenance.md", -]; - -/** - * Upstream files this fork deliberately does not have, and why. A sync raises a modify/delete - * conflict when upstream touches one, which is visible -- but resolving that conflict by taking - * upstream's side restores the file silently, which is not. Checked so each removal stays a - * decision rather than something that quietly drifts back. - */ -export const REMOVED_UPSTREAM_PATHS: Record = { - ".github/workflows/cla.yml": - "Cloudflare's CLA assistant: signs against cloudflare.com/cla and stores signatures on a " + - "`cla-signatures` branch this fork does not have, so it only ever fails here.", - ".github/workflows/bonk.yml": - "Cloudflare's internal review bot, which needs a GitHub App installation this fork lacks.", - ".github/workflows/bonk-pr.yml": - "The PR half of the same bot; its break-glass path also assumes that App.", - ".github/workflows/contribution-policy.yml": - "Enforces Cloudflare's policy on Cloudflare's repository -- it closes outside PRs and points " + - "contributors at cloudflare/cloudflare-os. Whether this fork takes contributions is our call.", - "scripts/contribution-policy.ts": - "Only consumer was contribution-policy.yml, via actions/github-script.", - "scripts/contribution-policy.test.ts": - "Tests the above, and reads the workflow file, so it cannot outlive either.", -}; - /** Extensions the formatting comparison understands. Anything else is left alone. */ const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; @@ -195,9 +140,9 @@ export function normalizeForFormatComparison(source: string): string { return text.trim(); } -/** True for paths whose content is ours alone, where upstream can never conflict. */ +/** True for Tier-1 paths, whose content is ours alone -- see `fork-boundary.json`. */ export function isForkOwned(path: string): boolean { - return FORK_OWNED_PREFIXES.some(prefix => path.startsWith(prefix)); + return forkBoundary().forkOwned.some(entry => path.startsWith(entry.path)); } /** @@ -421,7 +366,7 @@ export function auditFormatDrift(opts: { // Upstream-only comment changes are not fork churn. Unavailable ancestry cannot grant a skip. if (baseRef && gitOrNull(["show", `${baseRef}:${path}`]) === ours) continue; - const exception = FORMAT_EXCEPTIONS.find(entry => entry.path === path && + const exception = forkBoundary().formatExceptions.find(entry => entry.path === path && entry.upstreamBlob === git(["rev-parse", `${upstreamRef}:${path}`]).trim() && entry.forkBlob === git(["rev-parse", `${oursRef}:${path}`]).trim()); if (exception) { @@ -438,11 +383,34 @@ export function auditFormatDrift(opts: { /** Deliberately-removed upstream files that have come back. */ export function auditRemovedPaths(oursRef: string): string[] { - return Object.keys(REMOVED_UPSTREAM_PATHS) + return Object.keys(forkBoundary().removedUpstreamPaths) .filter(path => blob(oursRef, path) !== null) .toSorted(); } +/** A Tier-1 fork-owned prefix that also exists upstream, breaking the Tier-1 claim. */ +export interface OwnedPrefixCollision { + prefix: string; + /** Upstream paths at or under the prefix. */ + paths: string[]; +} + +/** + * Tier-1 prefixes upstream also has a file under. Either side can create one through an ordinary + * PR -- the fork by claiming a prefix upstream already has, upstream by growing into a claimed + * one -- so this needs no merge, like the formatting check. + */ +export function auditOwnedPrefixCollisions(upstreamRef: string): OwnedPrefixCollision[] { + const collisions: OwnedPrefixCollision[] = []; + for (const { path: prefix } of forkBoundary().forkOwned) { + const spec = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + const paths = git(["ls-tree", "-r", "--name-only", upstreamRef, "--", spec]) + .split("\n").filter(Boolean); + if (paths.length > 0) collisions.push({ prefix, paths }); + } + return collisions; +} + /** Bad invocation, as opposed to a finding. Exits 2 so callers can tell them apart. */ export class UsageError extends Error { } @@ -502,6 +470,7 @@ function main(argv: string[]): number { `Reviewed comment-only exception: ${exception.path}\n ${exception.reason}`), }) : []; const restored = auditRemovedPaths(oursRef); + const collisions = upstreamRef ? auditOwnedPrefixCollisions(upstreamRef) : []; const shallow = isShallowRepository(); const trustworthy = authoritative !== null && !shallow && merge?.classification !== "unverified"; @@ -552,13 +521,25 @@ function main(argv: string[]): number { if (restored.length > 0) { console.error("Upstream files this fork removed on purpose have come back:\n"); for (const path of restored) { - console.error(` ${path}\n removed because: ${REMOVED_UPSTREAM_PATHS[path]}`); + console.error(` ${path}\n removed because: ${forkBoundary().removedUpstreamPaths[path]}`); } - console.error("\n Remove again, or drop it from REMOVED_UPSTREAM_PATHS if the removal is " + + console.error("\n Remove again, or drop it from fork-boundary.json if the removal is " + "no longer wanted.\n"); } - if (dropped.length > 0 || formatChurn.length > 0 || restored.length > 0) return 1; + if (collisions.length > 0) { + console.error("Fork-owned (Tier-1) paths that also exist upstream:\n"); + for (const { prefix, paths } of collisions) { + console.error(` ${prefix}`); + for (const path of paths) console.error(` upstream has: ${path}`); + } + console.error("\n A Tier-1 entry asserts upstream has no file here. These paths are Tier 2 --\n" + + " fork-modified upstream content, resolved by hand at each sync. Drop the entry from\n" + + " fork-boundary.json and record the divergence in docs/fork-maintenance.md.\n"); + } + + if (dropped.length > 0 || formatChurn.length > 0 || restored.length > 0 || + collisions.length > 0) return 1; // Nothing found -- but "found nothing" and "could not look" are different answers, and only one // of them is a pass. Exiting 0 here would let a shell script or a habit-formed developer read an @@ -570,7 +551,7 @@ function main(argv: string[]): number { } console.log("Clean: no dropped upstream hunks, no formatting-only divergence, " + - "no removed files restored."); + "no removed files restored, no Tier-1 collisions."); return 0; } From ff0473127576514a4f5baf281a43b77ea3a9e2a4 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Mon, 14 Sep 2026 13:35:24 -0400 Subject: [PATCH 13/15] fix(fork): default PUBLIC_BASE_URL in dev-server The token-based connect handoff (upstream #464/#473) fails closed when PUBLIC_BASE_URL is unset. Default it to the browser-facing workshop origin so local connects work without exporting it by hand. --- scripts/run-dev-server.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/run-dev-server.ts b/scripts/run-dev-server.ts index e1402bcbde..b00438307d 100644 --- a/scripts/run-dev-server.ts +++ b/scripts/run-dev-server.ts @@ -556,6 +556,14 @@ for (const gk of gatekeepers) { if (process.env[name] !== undefined) config.vars[name] = process.env[name]; } + // The connect handoff targetOrigin must be the browser-facing workshop origin: the Vite dev + // server (fixed at :3000 in workshop-frontend/vite.config.ts) in normal dev mode, or this + // backend itself when it serves the pre-built bundle (--serve-frontend-assets). Without it + // every connect/redirect fails closed. + config.vars.PUBLIC_BASE_URL ??= serveFrontendAssets + ? `http://${backendHost}` + : "http://localhost:3000"; + for (const gk of gatekeepers) { const binding: ServiceBinding = { binding: bindingName(gk), From b23db94d92f854bbc327b4582abf454a7e269a4c Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Mon, 14 Sep 2026 15:40:12 -0400 Subject: [PATCH 14/15] fix(fork): handle fork deployment config in manifest, staging, and harness The fork's deployment tuning (ae28b29b) added wrangler keys the repo's own tooling fails closed on: - manifest-lib: read and drop limits/placement (no v1-contract field; customer instances get platform defaults), with a regression test - staging-config: strip dev-only browser.remote from generated preview bindings - integration harness: drop the backend assets binding for test boots (inline configs resolve assets.directory against the harness root; tests drive /api directly) Regenerate golden-manifest.json (backend gains assetsConfig + ASSETS binding). --- packages/integration-tests/src/harness.ts | 8 +++++ scripts/preview/staging-config.ts | 11 ++++-- scripts/release/manifest-lib.test.ts | 17 +++++++++- scripts/release/manifest-lib.ts | 9 +++++ scripts/release/testdata/golden-manifest.json | 34 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/integration-tests/src/harness.ts b/packages/integration-tests/src/harness.ts index b28d237c26..e8e1da0193 100644 --- a/packages/integration-tests/src/harness.ts +++ b/packages/integration-tests/src/harness.ts @@ -49,6 +49,7 @@ const WORKER_CONFIG = z.looseObject({ })).optional(), vars: z.record(z.string(), z.unknown()).optional(), worker_loaders: z.unknown().optional(), + assets: z.unknown().optional(), }); /** A parsed wrangler.jsonc, typed on the fields the harness (or a `patch` callback) works with. */ @@ -115,6 +116,13 @@ function workshopConfig( // executeCode or a generated Gadget server. if (!enableGadgetExecution) delete config.worker_loaders; + // The backend serves the frontend bundle from its own assets binding in deployments, but an + // inline config has no file path of its own, so wrangler resolves the relative + // assets.directory against the harness root instead of the backend directory (the same trap + // readWorkerConfig pins `main` against) — and tests drive /api directly, never the served + // bundle. Drop it so the suite boots without a frontend build. + delete config.assets; + patch?.(config); return config; } diff --git a/scripts/preview/staging-config.ts b/scripts/preview/staging-config.ts index 6583b8748d..1e3ccf9b8f 100644 --- a/scripts/preview/staging-config.ts +++ b/scripts/preview/staging-config.ts @@ -311,6 +311,13 @@ function routerGatekeeperServices(gatekeepers: string[]): PreviewService[] { })); } +// `remote` is dev-only wrangler behavior (use the remote browser during `wrangler dev`); +// a deployed preview binding is just { binding }. Rebuild it rather than passing the stanza +// through, or a `remote: true` kept for local development leaks into every preview config. +function previewBrowserBinding(browser: BindingDecl): BindingDecl { + return { binding: browser.binding }; +} + function applyGatekeeper( pkgName: string, config: StagingConfig, @@ -328,7 +335,7 @@ function applyGatekeeper( ...(config.unsafe ? { unsafe: config.unsafe } : {}), ...(config.artifacts ? { artifacts: config.artifacts } : {}), ...(config.ai ? { ai: config.ai } : {}), - ...(config.browser ? { browser: config.browser } : {}), + ...(config.browser ? { browser: previewBrowserBinding(config.browser) } : {}), }; // KV namespaces and R2 buckets are auto-provisioned per preview: each preview gets its own, @@ -365,7 +372,7 @@ function applyBackend( r2_buckets: previewResourceBindings(config.r2_buckets), worker_loaders: previewResourceBindings(config.worker_loaders), ai: config.ai, - ...(config.browser ? { browser: config.browser } : {}), + ...(config.browser ? { browser: previewBrowserBinding(config.browser) } : {}), }; } diff --git a/scripts/release/manifest-lib.test.ts b/scripts/release/manifest-lib.test.ts index 6610243f72..878ce797e6 100644 --- a/scripts/release/manifest-lib.test.ts +++ b/scripts/release/manifest-lib.test.ts @@ -13,7 +13,8 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { collectAssets, collectModules, stableStringify } from "./hash-lib.ts"; import { - generateManifest, readDeployablePackages, readDeployInputs, releaseShortName, + buildWorkerEntry, generateManifest, readDeployablePackages, readDeployInputs, + releaseShortName, } from "./manifest-lib.ts"; const RELEASE = dirname(fileURLToPath(import.meta.url)); @@ -206,6 +207,20 @@ test("worker entries carry the deploy contract", () => { } }); +// `limits`/`placement` are first-party deployment tuning with no v1-contract field: the +// generator accepts them on any worker and the entry carries neither (customer instances +// get platform defaults). +test("worker limits and placement are read and dropped", () => { + const entry = buildWorkerEntry({ + pkgName: "workshop-backend", + config: { limits: { cpu_ms: 300000 }, placement: { mode: "smart" } }, + mainModule: "server.js", + modules: [], + }); + assert.ok(!("limits" in entry) && !("placement" in entry), + "limits/placement must not reach the deploy contract"); +}); + // The deploy wizard sends a gatekeeper's manifest shortName as the install slug verbatim, and // the slug becomes a GATEKEEPER_ binding name, so the deploy service rejects anything // outside this charset (packages/deploy/src/naming.ts). A shortName that fails here reaches diff --git a/scripts/release/manifest-lib.ts b/scripts/release/manifest-lib.ts index c0342491d2..3da83ca800 100644 --- a/scripts/release/manifest-lib.ts +++ b/scripts/release/manifest-lib.ts @@ -116,6 +116,10 @@ export interface WranglerConfig { browser?: BindingDecl; /** Artifacts binding — closed beta, cut from customer manifests. */ artifacts?: BindingDecl; + /** Worker limits. First-party deployment tuning; cut from customer manifests. */ + limits?: { cpu_ms?: number }; + /** Smart Placement mode. First-party deployment tuning; cut from customer manifests. */ + placement?: { mode?: string }; /** Static-asset serving config (the router). */ assets?: { binding?: string; @@ -251,6 +255,11 @@ const HANDLED_CONFIG_KEYS = new Set([ // gatekeeper-context's Artifacts binding is closed-beta and cannot be provisioned in arbitrary // user accounts; it is dropped from customer manifests (the gatekeeper degrades gracefully). "artifacts", + // Worker limits (cpu_ms) and Smart Placement tune first-party deployments with long-running + // invocations. The v1 deploy contract has no field for them — the renderer would not know + // what to do with one — so customer instances get platform defaults. If it ever learns + // them, emit them from buildWorkerEntry and bump MANIFEST_VERSION. + "limits", "placement", ]); const ARTIFACTS_CUT_ALLOWED = new Set(["gatekeeper-context"]); diff --git a/scripts/release/testdata/golden-manifest.json b/scripts/release/testdata/golden-manifest.json index 2048fa20a9..a39fabf8d5 100644 --- a/scripts/release/testdata/golden-manifest.json +++ b/scripts/release/testdata/golden-manifest.json @@ -1088,6 +1088,36 @@ "vars": {} }, "workshop-backend": { + "assetsConfig": { + "not_found_handling": "single-page-application", + "run_worker_first": [ + "/api", + "/api/*", + "/blueprint-screenshot/*" + ], + "variants": { + "access": { + "manifest": { + "/assets/app.js": { + "hash": "8d2e76555a7166d0e6b7da22cabfa055", + "size": 28 + }, + "/assets/print.css": { + "hash": "924064d945426fd30c24d0d24235610d", + "size": 19 + }, + "/assets/shared.css": { + "hash": "924064d945426fd30c24d0d24235610d", + "size": 19 + }, + "/index.html": { + "hash": "03a936d819bd1a6df9f9d9f16f1df3a5", + "size": 41 + } + } + } + } + }, "bindings": [ { "name": "BLUEPRINTS", @@ -1112,6 +1142,10 @@ "name": "LOADER", "type": "worker_loader" }, + { + "name": "ASSETS", + "type": "assets" + }, { "name": "WORKERS_AI", "type": "ai" From a8815836328181401b3752bd351dacc2cf430467 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Mon, 14 Sep 2026 15:48:44 -0400 Subject: [PATCH 15/15] fix(fork): drop dev-only browser.remote; keep backend assets out of normal dev browser.remote only selects wrangler dev's remote proxy (wrangler strips it from deployed configs), so it enabled nothing in production. Remove it from the backend config and pin the real prod requirement - the BROWSER binding shipping in the release manifest - in the deploy-contract test. The fork's production backend assets stanza flowed into generated dev configs; normal dev serves the frontend from Vite and must not require a frontend build. run-dev-server now drops backend assets outside --serve-frontend-assets mode (run-local keeps them). --- packages/workshop-backend/wrangler.jsonc | 3 +-- scripts/release/manifest-lib.test.ts | 4 ++++ scripts/run-dev-server.ts | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/workshop-backend/wrangler.jsonc b/packages/workshop-backend/wrangler.jsonc index 5a9165ac24..b7d0ca716c 100644 --- a/packages/workshop-backend/wrangler.jsonc +++ b/packages/workshop-backend/wrangler.jsonc @@ -31,8 +31,7 @@ // Wrangler will launch a Chrome instance locally to emulate the Browser Run API during // local development. Add "remote": true to use a remote browser running on Cloudflare instead. "browser": { - "binding": "BROWSER", - "remote": true + "binding": "BROWSER" }, // Gatekeeper service bindings and the Workers AI binding are dynamically // added by run-dev-server.ts (for dev) and generate-wrangler-prod.js (for diff --git a/scripts/release/manifest-lib.test.ts b/scripts/release/manifest-lib.test.ts index 878ce797e6..ae47a32b0f 100644 --- a/scripts/release/manifest-lib.test.ts +++ b/scripts/release/manifest-lib.test.ts @@ -115,6 +115,10 @@ test("worker entries carry the deploy contract", () => { assert.deepEqual( backend.bindings.find((b) => b.name === "WORKERS_AI"), { type: "ai", name: "WORKERS_AI" }); + // Gadget exports render in a real browser; the binding ships to customer instances. + assert.deepEqual( + backend.bindings.find((b) => b.name === "BROWSER"), + { type: "browser", name: "BROWSER" }); assert.ok(backend.gatekeeperBindingExpansion); assert.equal(backend.gatekeeperBindingExpansion.entrypoint, "GatekeeperVendor"); assert.equal(backend.vars.PUBLIC_BASE_URL, "$PUBLIC_BASE_URL"); diff --git a/scripts/run-dev-server.ts b/scripts/run-dev-server.ts index 96853929fc..1df69a9451 100644 --- a/scripts/run-dev-server.ts +++ b/scripts/run-dev-server.ts @@ -593,6 +593,11 @@ for (const gk of gatekeepers) { not_found_handling: "single-page-application", run_worker_first: ["/api", "/api/*", "/blueprint-screenshot/*"], }; + } else { + // Normal dev serves the frontend from Vite (:3000), not from the backend worker: drop any + // assets stanza the checked-in config carries (production serves the pre-built bundle from + // the backend) so starting the dev server needs no `vite build`. + delete config.assets; } config.build = devBuildConfig(config.build, WORKSHOP_BACKEND_DIR);