From 4fb41e8b374e2215b8e90d57aa8dc123c56e5116 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 11:00:26 -0500 Subject: [PATCH 1/6] Add Google Drive native document sessions --- packages/gatekeeper-google/README.md | 18 ++- .../__tests__/configurator-url.test.ts | 27 +++- .../__tests__/drive-session.test.ts | 123 ++++++++++++++++- .../__tests__/native-api.test.ts | 83 ++++++++++++ .../__tests__/resources.test.ts | 45 ++++++- .../gatekeeper-google/__tests__/types.test.ts | 57 ++++++++ .../__tests__/workerd/native-sessions.test.ts | 110 +++++++++++++++ packages/gatekeeper-google/package.json | 4 +- .../drive-account-configurator-ui.tsx | 4 +- .../drive-file-configurator-ui.tsx | 2 +- .../shared-drive-configurator-ui.tsx | 2 +- packages/gatekeeper-google/src/docs-api.ts | 63 ++++----- .../src/docs-read-types.d.ts | 17 +++ .../gatekeeper-google/src/docs-read-types.txt | 1 + .../gatekeeper-google/src/docs-types.d.ts | 17 +-- .../gatekeeper-google/src/drive-session.ts | 24 ++++ .../gatekeeper-google/src/drive-types.d.ts | 24 +++- .../gatekeeper-google/src/drive-types.txt | 21 ++- .../gatekeeper-google/src/google-response.ts | 66 +++++++++ packages/gatekeeper-google/src/google.ts | 125 +++++++++++++++--- packages/gatekeeper-google/src/resources.ts | 24 ++-- packages/gatekeeper-google/src/sheets-api.ts | 88 ++---------- packages/gatekeeper-google/vite.config.ts | 11 +- .../gatekeeper-google/vitest.worker.config.ts | 22 +++ pnpm-lock.yaml | 6 + 25 files changed, 801 insertions(+), 183 deletions(-) create mode 100644 packages/gatekeeper-google/__tests__/native-api.test.ts create mode 100644 packages/gatekeeper-google/__tests__/types.test.ts create mode 100644 packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts create mode 100644 packages/gatekeeper-google/src/docs-read-types.d.ts create mode 120000 packages/gatekeeper-google/src/docs-read-types.txt create mode 100644 packages/gatekeeper-google/src/google-response.ts create mode 100644 packages/gatekeeper-google/vitest.worker.config.ts diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index 7027b325f..25fb317c9 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -51,7 +51,7 @@ You'll need to enable the Google APIs that you want to use. Currently supported: 18. Click on **BigQuery API** in the results 19. Click **Enable** -The Google Drive API powers the Docs and Sheets resource pickers and the read-only Drive metadata bindings described below. Document reads and edits still go through the Google Docs API, and spreadsheet reads go through the Google Sheets API. +The Google Drive API powers the Docs and Sheets resource pickers, Drive discovery, and Drive scope checks. Native document or spreadsheet content opened from a Drive binding is read through the Google Docs or Google Sheets API. Direct Google Doc reads and edits still go through the Docs API, and direct spreadsheet reads go through the Sheets API. ### Step 3: Configure the OAuth Consent Screen @@ -74,10 +74,10 @@ included). Across all resource types, the gatekeeper can request: - `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account. - `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending. -- `documents` for Google Docs reads and edits. -- `drive.metadata.readonly` for the Docs and Sheets pickers, connected-account Drive metadata, and exact-file metadata. -- `drive.readonly` for metadata search within one shared drive; the gatekeeper still exposes metadata only. -- `spreadsheets.readonly` to read metadata and cell values from selected Google spreadsheets. +- `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide or exact-file Drive bindings. +- `drive.metadata.readonly` for the Docs and Sheets pickers, account-wide Drive discovery, exact-file metadata, and native-file scope checks. +- `drive.readonly` for discovery and native Docs or Sheets reads within one shared drive. Google accepts this Drive scope for those native APIs, so shared-drive grants do not need redundant Docs or Sheets scopes. +- `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide or exact-file Drive bindings. - `calendar.calendarlist.readonly` so the resource picker can list calendars. - `calendar.events` to manage selected calendar and check calendar availability. - `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries. @@ -156,9 +156,13 @@ Drive exposes three permanent resource URL forms: Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs. -The agent-facing `GoogleDriveSession` returns metadata only. It can report the binding scope, list entries, run structured metadata searches, and fetch one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. The API does not expose raw Drive `q` strings, file contents, writes, shortcut traversal, native Docs or Sheets sessions, or Workers AI extraction. +The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured metadata searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata and Markdown content; Sheets expose spreadsheet metadata and bounded A1 range reads. -Account and shared-drive bindings remember every file ID whose metadata a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires an explicit Drive grant and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page is disclosed, it checks the page's IDs against every existing observer and excludes observers who cannot access them. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests; there is deliberately no cached access verdict, so revoked access fails closed on the next open. +Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. The Drive API exposes no raw `q` strings, writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. + +Account-wide and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting either resource. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. + +Account and shared-drive bindings remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires an explicit Drive grant and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests; there is deliberately no cached access verdict, so revoked access fails closed on the next open. ## Troubleshooting diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index 467c5c8f5..5c1427b56 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -7,7 +7,17 @@ // `encodeURIComponent`, a normalization one side does and the other does not -- shows up here // rather than as a resource the backend rejects after the user has filled the form. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@gadgets/configurator-ui", () => ({ + h: (component: unknown, props: unknown, ...children: unknown[]) => ({ + component, props, children, + }), + Autocomplete: "Autocomplete", + Field: "Field", + RadioCards: "RadioCards", + Section: "Section", +})); import driveAccountConfigurator from "../src/configurator/drive-account-configurator-ui"; import driveFileConfigurator from "../src/configurator/drive-file-configurator-ui"; import gmailConfigurator from "../src/configurator/gmail-configurator-ui"; @@ -43,6 +53,9 @@ const configurableValues = ( resourceUrl: string, resourceUrlPattern: string, ) => configurator.initialValuesFromResourceUrl!({ resourceUrl, resourceUrlPattern, ui: noUi }); + +const renderedCopy = (configurator: { render?: (context: never) => unknown }) => + JSON.stringify(configurator.render!({ values: {}, setValues() {}, ui: noUi } as never)); describe("Gmail configurator URLs", () => { it.for([ ["the whole mailbox", { mode: "all" }, { kind: "gmail" }], @@ -106,6 +119,18 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" }); }); + it("explains native Doc and Sheet reads at every Drive scope", () => { + expect(renderedCopy(driveAccountConfigurator)).toContain( + "Returns metadata for every item and read-only content sessions for native Docs and Sheets.", + ); + expect(renderedCopy(sharedDriveConfigurator)).toContain( + "Search its files and read native Google Docs and Sheets.", + ); + expect(renderedCopy(driveFileConfigurator)).toContain( + "A selected native Google Doc or Sheet also provides read-only content.", + ); + }); + it("round-trips an encoded shared-drive ID", () => { let values = { driveId: "shared/id with spaces" }; let url = configurableUrl(sharedDriveConfigurator, values); diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index e232d1207..e5f6e402b 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; import { DriveSessionCore, driveFileToEntry } from "../src/drive-session"; +import type { ObserverCheck } from "../src/observers"; import type { DriveFile } from "../src/drive-api"; const file = (overrides: Partial = {}): DriveFile => ({ @@ -17,6 +18,8 @@ function core(overrides: { files?: DriveFile[]; getFile?: (id: string) => Promise; getDrive?: (id: string) => Promise<{ id: string; name: string }>; + prepareObservation?: (ids: string[]) => Promise>; + authorize?: (description: ObservationDescription) => Promise; } = {}) { let listFiles = vi.fn(async () => ({ files: overrides.files ?? [file()] })); let getFile = vi.fn(overrides.getFile ?? (async (id: string) => file({ id }))); @@ -28,18 +31,18 @@ function core(overrides: { let session = new DriveSessionCore({ api: { listFiles, getFile, getDrive }, scope: overrides.scope ?? { kind: "account" }, - prepareObservation: async (ids: string[]) => { + prepareObservation: overrides.prepareObservation ?? (async (ids: string[]) => { prepared.push(ids); return { excludeObservers: ["excluded"], pendingSets: ids, commit: () => events.push("commit"), }; - }, - authorize: async (description: ObservationDescription) => { + }), + authorize: overrides.authorize ?? (async (description: ObservationDescription) => { authorizations.push(description); events.push("authorize"); - }, + }), }); return { session, listFiles, getFile, getDrive, prepared, authorizations, events }; } @@ -151,6 +154,118 @@ describe("Drive session scope", () => { }); }); +describe("Drive native sessions", () => { + const docMime = "application/vnd.google-apps.document"; + const sheetMime = "application/vnd.google-apps.spreadsheet"; + + it.each([ + ["account Doc", { kind: "account" } as const, docMime, "Google Doc"], + ["account Sheet", { kind: "account" } as const, sheetMime, "Google Sheet"], + ["shared-drive Doc", { kind: "sharedDrive", driveId: "drive-1" } as const, + docMime, "Google Doc"], + ["shared-drive Sheet", { kind: "sharedDrive", driveId: "drive-1" } as const, + sheetMime, "Google Sheet"], + ["exact-file Doc", { kind: "file", fileId: "file-1" } as const, + docMime, "Google Doc"], + ["exact-file Sheet", { kind: "file", fileId: "file-1" } as const, + sheetMime, "Google Sheet"], + ])("opens an in-scope native %s", async (_name, scope, mimeType, description) => { + let { session, getFile } = core({ + scope, + getFile: async id => file({ + id, + mimeType, + ...(scope.kind === "sharedDrive" ? { driveId: scope.driveId } : {}), + }), + }); + + await expect(session.openNativeFile("file-1", mimeType, description)) + .resolves.toBe("file-1"); + expect(getFile).toHaveBeenCalledWith("file-1"); + }); + + it("rejects another exact-file ID before calling Google", async () => { + let { session, getFile } = core({ scope: { kind: "file", fileId: "file-1" } }); + + await expect(session.openNativeFile("file-2", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + expect(getFile).not.toHaveBeenCalled(); + }); + + it("rejects a foreign shared-drive file without authorizing or tracking it", async () => { + let { session, prepared, authorizations } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async id => file({ id, driveId: "drive-2", mimeType: docMime }), + }); + + await expect(session.openNativeFile("foreign", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + expect(prepared).toEqual([]); + expect(authorizations).toEqual([]); + }); + + it.each([ + ["wrong native type", sheetMime, undefined], + ["folder", "application/vnd.google-apps.folder", undefined], + ["blob", "application/pdf", undefined], + ["shortcut", "application/vnd.google-apps.shortcut", { targetId: "target-1" }], + ])("observes a %s before rejecting its MIME type", async (_name, mimeType, shortcutDetails) => { + let { session, prepared, authorizations, events } = core({ + getFile: async id => file({ id, mimeType, shortcutDetails }), + }); + + await expect(session.openNativeFile("file-1", docMime, "Google Doc")) + .rejects.toThrow(/not a Google Doc/); + expect(prepared).toEqual([["file-1"]]); + expect(authorizations).toEqual([expect.objectContaining({ excludeObservers: ["excluded"] })]); + expect(events).toEqual(["authorize", "commit"]); + }); + + it("never follows a shortcut target implicitly", async () => { + let getFile = vi.fn(async (id: string) => file({ + id, + mimeType: "application/vnd.google-apps.shortcut", + shortcutDetails: { targetId: "target-1", targetMimeType: docMime }, + })); + let { session } = core({ getFile }); + + await expect(session.openNativeFile("shortcut-1", docMime, "Google Doc")) + .rejects.toThrow(/not a Google Doc/); + expect(getFile).toHaveBeenCalledTimes(1); + expect(getFile).toHaveBeenCalledWith("shortcut-1"); + }); + + it("forwards observer exclusions and commits only after authorization", async () => { + let { session, authorizations, events } = core({ + getFile: async id => file({ id, mimeType: docMime }), + }); + + await session.openNativeFile("file-1", docMime, "Google Doc"); + + expect(authorizations).toEqual([expect.objectContaining({ + title: "Open Google Doc from Google Drive", + excludeObservers: ["excluded"], + })]); + expect(events).toEqual(["authorize", "commit"]); + }); + + it("leaves a denied file observation pending rather than observed", async () => { + let state = "unknown"; + let { session } = core({ + getFile: async id => file({ id, mimeType: docMime }), + prepareObservation: async ids => { + state = "pending"; + return { pendingSets: ids, commit: () => { state = "observed"; } }; + }, + authorize: async () => { throw new Error("denied"); }, + }); + + await expect(session.openNativeFile("file-1", docMime, "Google Doc")) + .rejects.toThrow("denied"); + expect(state).toBe("pending"); + }); +}); + describe("Drive search validation", () => { it("requires at least one populated search filter", async () => { let { session } = core(); diff --git a/packages/gatekeeper-google/__tests__/native-api.test.ts b/packages/gatekeeper-google/__tests__/native-api.test.ts new file mode 100644 index 000000000..a0c2e6aca --- /dev/null +++ b/packages/gatekeeper-google/__tests__/native-api.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GoogleDocsApi } from "../src/docs-api"; +import { GoogleSheetsApi } from "../src/sheets-api"; + +const token = async () => "access-token"; + +function docBody() { + return { + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + body: { content: [] }, + lists: {}, + }; +} + +function sheetBody() { + return { + spreadsheetId: "sheet-1", + properties: { title: "Forecast" }, + sheets: [], + }; +} + +function oversizedResponse(cancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + }, + cancel, + }); + return new Response(body, { + headers: { "Content-Length": String(100 * 1024 * 1024) }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("native Google content API safety", () => { + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), docBody()], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), sheetBody()], + ] as const)("wires a finite timeout for %s reads", async (_provider, read, body) => { + const timeout = vi.spyOn(AbortSignal, "timeout"); + vi.stubGlobal("fetch", vi.fn(async () => Response.json(body))); + + await read(); + + expect(timeout).toHaveBeenCalledWith(30_000); + }); + + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1")], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1")], + ] as const)("cancels an oversized successful %s response", async (_provider, read) => { + const cancel = vi.fn(); + vi.stubGlobal("fetch", vi.fn(async () => oversizedResponse(cancel))); + + await expect(read()).rejects.toThrow(/response exceeded/); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it.each([ + ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), + "Google Docs get document failed [http=403]"], + ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), + "Google Sheets get spreadsheet failed [http=403]"], + ] as const)("redacts %s provider response prose", async (_provider, read, expected) => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ + error: { message: "secret provider response prose" }, + }, { status: 403 }))); + + const error = await read().catch(value => value as Error); + + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) throw new Error("Expected provider read to fail"); + expect(error.message).toBe(expected); + expect(error.message).not.toContain("secret provider"); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index a788befd6..afcfea9c3 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -54,6 +54,18 @@ describe("resource declarations", () => { } expect(new Set(Object.values(RESOURCE_BY_KIND)).size).toBe(SUPPORTED_RESOURCES.length); }); + + it("advertises native Docs and Sheets only on Drive resources", () => { + expect([ + GOOGLE_DRIVE_RESOURCE.description, + GOOGLE_SHARED_DRIVE_RESOURCE.description, + GOOGLE_DRIVE_FILE_RESOURCE.description, + ]).toEqual([ + "Find files and folders, and read native Google Docs and Sheets, in My Drive or Shared with me.", + "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", + "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", + ]); + }); }); describe("resourceUrlPatternsToOAuthScopes", () => { @@ -76,14 +88,37 @@ describe("resourceUrlPatternsToOAuthScopes", () => { }); it.each([ - [GOOGLE_DRIVE_RESOURCE, "https://www.googleapis.com/auth/drive.metadata.readonly"], - [GOOGLE_SHARED_DRIVE_RESOURCE, "https://www.googleapis.com/auth/drive.readonly"], - [GOOGLE_DRIVE_FILE_RESOURCE, "https://www.googleapis.com/auth/drive.metadata.readonly"], - ] as const)("uses the permanent least-privilege scope for $urlPattern", (resource, scope) => { + [GOOGLE_DRIVE_RESOURCE, [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ]], + [GOOGLE_SHARED_DRIVE_RESOURCE, ["https://www.googleapis.com/auth/drive.readonly"]], + [GOOGLE_DRIVE_FILE_RESOURCE, [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ]], + ] as const)("uses the permanent least-privilege scopes for $urlPattern", (resource, scopes) => { expect(resourceUrlPatternsToOAuthScopes([resource.urlPattern])).toEqual([ - ...IDENTITY_SCOPES, scope, + ...IDENTITY_SCOPES, ...scopes, ]); }); + + it("requires account and file grants to expand beyond metadata-only consent", () => { + const oldMetadataGrant = [ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/drive.metadata.readonly", + ]; + const granted = grantedResourcesFromScopes(oldMetadataGrant); + + expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); + expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); + expect(grantedResourcesFromScopes([ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/drive.readonly", + ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + }); it("deduplicates scopes shared between resources", () => { let scopes = resourceUrlPatternsToOAuthScopes( [GOOGLE_DOC_RESOURCE.urlPattern, GOOGLE_SHEETS_RESOURCE.urlPattern]); diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts new file mode 100644 index 000000000..dafa732a1 --- /dev/null +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -0,0 +1,57 @@ +/// + +import { lstatSync, readFileSync, readlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src"); + +function sourcePath(name: string): string { + return join(SOURCE_DIR, name); +} + +function source(name: string): string { + return readFileSync(sourcePath(name), "utf8"); +} + +describe("embedded agent declarations", () => { + for (const name of ["docs-read-types", "docs-types"]) { + it(`keeps ${name}.txt linked to its TypeScript declaration`, () => { + const textUrl = sourcePath(`${name}.txt`); + expect(lstatSync(textUrl).isSymbolicLink()).toBe(true); + expect(readlinkSync(textUrl)).toBe(`${name}.d.ts`); + expect(source(`${name}.txt`)).toBe(source(`${name}.d.ts`)); + }); + } + + it("keeps the Drive declaration aligned after module-only imports", () => { + const modulePrefix = + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + + 'import type { GoogleSpreadsheetSession } from "./sheets-types";\n\n'; + const driveTypes = source("drive-types.d.ts"); + expect(driveTypes.startsWith(modulePrefix)).toBe(true); + expect(source("drive-types.txt")).toBe(driveTypes.slice(modulePrefix.length)); + }); + + 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( + "export interface GoogleDocSession extends GoogleDocReadSession", + ); + }); + + it("exposes only typed native content sessions from Drive", () => { + const driveTypes = source("drive-types.d.ts"); + expect(driveTypes).toContain( + "openGoogleDoc(fileId: string): Promise", + ); + expect(driveTypes).toContain( + "openGoogleSheet(fileId: string): Promise", + ); + expect(driveTypes).not.toContain("GoogleDocSession>"); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts new file mode 100644 index 000000000..2b46ffe2f --- /dev/null +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -0,0 +1,110 @@ +import { RpcStub, RpcTarget } from "cloudflare:workers"; +import type { ApprovalQueue, ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GoogleDocsApi } from "../../src/docs-api"; +import { DriveApi } from "../../src/drive-api"; +import { GoogleDriveSessionImpl } from "../../src/google"; +import { GoogleSheetsApi } from "../../src/sheets-api"; + +const DOC_MIME = "application/vnd.google-apps.document"; +const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; + +async function getAccessToken(): Promise { + return "access-token"; +} + +class TestApprovalQueue extends RpcTarget { + readonly observations: ObservationDescription[] = []; + + async authorizeObservation(description: ObservationDescription): Promise { + this.observations.push(description); + } +} + +function providerFile(id: string, mimeType: string) { + return { + id, + name: id === "doc-1" ? "Quarterly plan" : "Forecast", + mimeType, + modifiedTime: "2026-08-20T12:00:00Z", + }; +} + +function installProvider() { + const urls: string[] = []; + 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()); + if (url.hostname === "www.googleapis.com" && url.pathname.includes("/drive/v3/files/")) { + const id = decodeURIComponent(url.pathname.split("/").at(-1)!); + const mimeType = id === "doc-1" ? DOC_MIME : SHEET_MIME; + return Response.json(providerFile(id, mimeType)); + } + if (url.hostname === "docs.googleapis.com") { + return Response.json({ + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + body: { content: [] }, + lists: {}, + }); + } + throw new Error(`Unexpected provider request: ${url.origin}${url.pathname}`); + })); + return urls; +} + +function newSession() { + const queue = new TestApprovalQueue(); + const session = new GoogleDriveSessionImpl( + new DriveApi(getAccessToken), + new GoogleDocsApi(getAccessToken), + new GoogleSheetsApi(getAccessToken), + { kind: "account" }, + new RpcStub(queue) as unknown as RpcStub, + ); + return { queue, session }; +} + +beforeEach(() => installProvider()); +afterEach(() => vi.unstubAllGlobals()); + +describe("Drive nested native sessions", () => { + it("returns a Doc target with only the read surface", async () => { + const { session } = newSession(); + + const doc = await session.openGoogleDoc("doc-1"); + + expect(await doc.getMetadata()).toEqual({ + title: "Quarterly plan", + lastModified: new Date("2026-08-20T12:00:00Z"), + }); + expect(await doc.getContent()).toBe(""); + expect("replaceText" in doc).toBe(false); + expect("appendText" in doc).toBe(false); + }); + + it("returns the existing Sheet target with bounded range validation", async () => { + const urls = installProvider(); + const { session } = newSession(); + + const sheet = await session.openGoogleSheet("sheet-1"); + + await expect(sheet.readRange("A:A")).rejects.toThrow(/Invalid or unbounded A1 range/); + expect(urls.some(url => url.includes("sheets.googleapis.com"))).toBe(false); + }); + + it("gives each child an independently disposable approval-queue stub", async () => { + const { queue, session } = newSession(); + const doc = await session.openGoogleDoc("doc-1"); + + session[Symbol.dispose](); + await expect(doc.getMetadata()).resolves.toEqual(expect.objectContaining({ + title: "Quarterly plan", + })); + expect(queue.observations).toHaveLength(2); + + (doc as typeof doc & Disposable)[Symbol.dispose](); + await expect(doc.getContent()).rejects.toThrow(); + }); +}); diff --git a/packages/gatekeeper-google/package.json b/packages/gatekeeper-google/package.json index ce68b7a53..fb043a7cd 100644 --- a/packages/gatekeeper-google/package.json +++ b/packages/gatekeeper-google/package.json @@ -7,7 +7,7 @@ "dev": "echo \"run 'pnpm dev-server' in the root directory instead\" >&2 && exit 1", "deploy": "vp run --no-cache build:configurator && wrangler deploy", "clean": "rm -rf dist src/generated", - "test:run": "vitest run" + "test:run": "vitest run && vitest run -c vitest.worker.config.ts" }, "dependencies": { "@gadgets/backend-utils": "workspace:*", @@ -19,6 +19,8 @@ "postal-mime": "^2.7.6" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "catalog:", + "miniflare": "5.20260801.0-alpha", "typescript": "catalog:", "vitest": "catalog:", "wrangler": "catalog:" diff --git a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx index 468cd99aa..4d58313ca 100644 --- a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx @@ -8,12 +8,12 @@ export default { initialValuesFromResourceUrl: () => ({ scope: "account" }), render({ setValues }) { return
- + setValues({ scope: "account" })} /> diff --git a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx index c05b4949e..386bf19fb 100644 --- a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx @@ -11,7 +11,7 @@ export default { }), render({ values, setValues, ui }) { return
- + - + ( + url: string, + init: RequestInit, + operation: string, + ): Promise { + let response = await fetchWithAuthRetry( + url, init, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, + ); + return readGoogleJson(response, { + provider: "Google Docs", operation, maxBytes: MAX_RESPONSE_BYTES, + }); + } + /** Fetch the full document. */ async getDocument(documentId: string): Promise { - let response = await fetchWithAuthRetry( + return this.#request( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}`, {}, - this.getAccessToken, + "get document", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to get document: ${response.status} ${errorText}`); - } - - return await response.json() as GoogleDocsDocument; } /** @@ -115,18 +125,11 @@ export class GoogleDocsApi { * that's fine — we just parse revisionId from whatever comes back. */ async getRevisionId(documentId: string): Promise { - let response = await fetchWithAuthRetry( + let data = await this.#request<{ revisionId: string }>( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}?fields=revisionId`, {}, - this.getAccessToken, + "get revision ID", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to get revision ID: ${response.status} ${errorText}`); - } - - let data = await response.json() as { revisionId: string }; return data.revisionId; } @@ -146,31 +149,19 @@ export class GoogleDocsApi { targetRevisionId?: string, ): Promise { let body: any = { requests }; - if (targetRevisionId) { - body.writeControl = { targetRevisionId }; - } + if (targetRevisionId) body.writeControl = { targetRevisionId }; - let response = await fetchWithAuthRetry( + let result = await this.#request<{ + writeControl?: { requiredRevisionId?: string }; + }>( `${DOCS_API_BASE}/${encodeURIComponent(documentId)}:batchUpdate`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, - this.getAccessToken, + "batch update document", ); - - if (!response.ok) { - let errorText = await response.text(); - throw new Error(`Failed to batch update document: ${response.status} ${errorText}`); - } - - let result = await response.json() as { - writeControl?: { requiredRevisionId?: string }; - }; - return result.writeControl?.requiredRevisionId ?? ""; } } diff --git a/packages/gatekeeper-google/src/docs-read-types.d.ts b/packages/gatekeeper-google/src/docs-read-types.d.ts new file mode 100644 index 000000000..1caf8d6f0 --- /dev/null +++ b/packages/gatekeeper-google/src/docs-read-types.d.ts @@ -0,0 +1,17 @@ +/** Metadata for one native Google Doc. */ +export type DocMetadata = { + /** Document title. */ + title: string; + + /** When the document was last modified. */ + lastModified: Date; +} + +/** Read-only access to one native Google Doc. */ +export interface GoogleDocReadSession { + /** Return current document metadata. */ + getMetadata(): Promise; + + /** Return the document body converted to Markdown. */ + getContent(): Promise; +} diff --git a/packages/gatekeeper-google/src/docs-read-types.txt b/packages/gatekeeper-google/src/docs-read-types.txt new file mode 120000 index 000000000..72de7fd4f --- /dev/null +++ b/packages/gatekeeper-google/src/docs-read-types.txt @@ -0,0 +1 @@ +docs-read-types.d.ts \ No newline at end of file diff --git a/packages/gatekeeper-google/src/docs-types.d.ts b/packages/gatekeeper-google/src/docs-types.d.ts index 4f4fad06a..9bc209a04 100644 --- a/packages/gatekeeper-google/src/docs-types.d.ts +++ b/packages/gatekeeper-google/src/docs-types.d.ts @@ -1,17 +1,8 @@ -export type DocMetadata = { - /** Document title. */ - title: string; +import type { GoogleDocReadSession } from "./docs-read-types"; +export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types"; - /** When the document was last modified. */ - lastModified: Date; -} - -export interface GoogleDocSession { - /** Get basic metadata about the document (title, last modified time). */ - getMetadata(): Promise; - - /** Get the full document content, converted to Markdown. */ - getContent(): Promise; +/** Read/write access to one directly bound native Google Doc. */ +export interface GoogleDocSession extends GoogleDocReadSession { /** * Find `oldMarkdown` in the current document content and replace it with `newMarkdown`. diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 6bc718eff..7de9f8acc 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -8,6 +8,10 @@ import type { const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; const SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; +/** Exact MIME type for native Google Docs files. */ +export const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; +/** Exact MIME type for native Google Sheets files. */ +export const GOOGLE_SHEET_MIME_TYPE = "application/vnd.google-apps.spreadsheet"; /** Immutable authority carried by one Drive gatekeeper binding. */ export type DriveBindingScope = @@ -183,6 +187,26 @@ export class DriveSessionCore { return entry; } + /** Validate and authorize one native file before a nested content session is created. */ + async openNativeFile( + fileId: string, + expectedMimeType: string, + description: string, + ): Promise { + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); + let file = await this.#api.getFile(fileId); + if (!this.#inScope(file)) this.#outsideScope(); + await this.#authorizeIds( + [file.id], + `Open ${description} from Google Drive`, + `Check current metadata for Drive file ${file.id} and open it as a ${description}.`, + ); + if (file.mimeType !== expectedMimeType) { + throw new Error(`The requested Drive file is not a ${description}.`); + } + return file.id; + } + #cursor(query: DriveListFilesOptions): Pager { return new CursorPager({ provider: "Google Drive", diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index 2e7a89363..594439ffd 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -1,3 +1,6 @@ +import type { GoogleDocReadSession } from "./docs-read-types"; +import type { GoogleSpreadsheetSession } from "./sheets-types"; + /** * A pagination cursor. * @@ -107,10 +110,9 @@ export type DriveSearchQuery = { }; /** - * Read-only metadata access to the selected Drive scope. + * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. * - * Methods do not return file contents, follow shortcut targets, edit Drive, or open native Google - * Docs or Sheets sessions. + * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ export interface GoogleDriveSession { /** Return the immutable binding scope with current display metadata. */ @@ -132,4 +134,20 @@ export interface GoogleDriveSession { /** Return metadata for one file ID, or throw when the ID is outside the immutable binding scope. */ getEntry(fileId: string): Promise; + + /** + * Open an in-scope native Google Doc with MIME type + * `application/vnd.google-apps.document`. Other MIME types, including folders and shortcuts, are + * rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleDoc(fileId: string): Promise; + + /** + * Open an in-scope native Google Sheet with MIME type + * `application/vnd.google-apps.spreadsheet`. Other MIME types, including folders and shortcuts, + * are rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleSheet(fileId: string): Promise; } diff --git a/packages/gatekeeper-google/src/drive-types.txt b/packages/gatekeeper-google/src/drive-types.txt index 2e7a89363..5cef69d45 100644 --- a/packages/gatekeeper-google/src/drive-types.txt +++ b/packages/gatekeeper-google/src/drive-types.txt @@ -107,10 +107,9 @@ export type DriveSearchQuery = { }; /** - * Read-only metadata access to the selected Drive scope. + * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. * - * Methods do not return file contents, follow shortcut targets, edit Drive, or open native Google - * Docs or Sheets sessions. + * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ export interface GoogleDriveSession { /** Return the immutable binding scope with current display metadata. */ @@ -132,4 +131,20 @@ export interface GoogleDriveSession { /** Return metadata for one file ID, or throw when the ID is outside the immutable binding scope. */ getEntry(fileId: string): Promise; + + /** + * Open an in-scope native Google Doc with MIME type + * `application/vnd.google-apps.document`. Other MIME types, including folders and shortcuts, are + * rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleDoc(fileId: string): Promise; + + /** + * Open an in-scope native Google Sheet with MIME type + * `application/vnd.google-apps.spreadsheet`. Other MIME types, including folders and shortcuts, + * are rejected. The returned RPC capability supports promise pipelining and must be disposed when + * finished. + */ + openGoogleSheet(fileId: string): Promise; } diff --git a/packages/gatekeeper-google/src/google-response.ts b/packages/gatekeeper-google/src/google-response.ts new file mode 100644 index 000000000..a939c4b60 --- /dev/null +++ b/packages/gatekeeper-google/src/google-response.ts @@ -0,0 +1,66 @@ +type GoogleJsonResponseOptions = { + provider: string; + operation: string; + maxBytes: number; +}; + +async function readBoundedText( + response: Response, + maxBytes: number, + provider: string, +): Promise { + let contentLength = response.headers.get("Content-Length"); + if (contentLength !== null) { + let declaredBytes = Number(contentLength); + if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error(`${provider} response exceeded the ${maxBytes}-byte limit.`); + } + } + + if (!response.body) return ""; + let reader = response.body.getReader(); + let chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + let { done, value } = await reader.read(); + if (done) break; + if (totalBytes + value.byteLength > maxBytes) { + await reader.cancel().catch(() => {}); + throw new Error(`${provider} response exceeded the ${maxBytes}-byte limit.`); + } + chunks.push(value); + totalBytes += value.byteLength; + } + } finally { + reader.releaseLock(); + } + + let bytes = new Uint8Array(totalBytes); + let offset = 0; + for (let chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +/** Read a size-bounded Google JSON response without exposing provider response prose in errors. */ +export async function readGoogleJson( + response: Response, + options: GoogleJsonResponseOptions, +): Promise { + let { provider, operation, maxBytes } = options; + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`${provider} ${operation} failed [http=${response.status}]`); + } + + let text = await readBoundedText(response, maxBytes, provider); + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`${provider} ${operation} returned invalid JSON.`); + } +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index e1ccfc916..5152903a1 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -6,7 +6,7 @@ import { GmailSession, GmailThread, GmailMessage, GmailThreadInfo, GmailThreadEntry, GmailMessageInfo, GmailLabel, GmailSystemLabel, EmailContent } from "./types"; -import { GoogleDocSession, DocMetadata } from "./docs-types"; +import { GoogleDocSession, DocMetadata, type GoogleDocReadSession } from "./docs-types"; import { GoogleDocsApi } from "./docs-api"; import { GoogleSheetsApi } from "./sheets-api"; import type { @@ -14,7 +14,9 @@ import type { } from "./sheets-types"; import { docToMarkdown, markdownToDocRequests, computeReplaceOperations, DocSnapshot } from "./markdown-converter"; import { DriveApi } from "./drive-api"; -import { DriveSessionCore, type DriveBindingScope } from "./drive-session"; +import { + DriveSessionCore, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, type DriveBindingScope, +} from "./drive-session"; import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; import { @@ -31,6 +33,7 @@ import type { GoogleCalendarInfo, GoogleCalendarSession, PersonAvailability, } from "./calendar-types"; import TYPES_CODE from "./types.txt"; +import DOCS_READ_TYPES_CODE from "./docs-read-types.txt"; import DOCS_TYPES_CODE from "./docs-types.txt"; import BIGQUERY_TYPES_CODE from "./bigquery-types.txt"; import CALENDAR_TYPES_CODE from "./calendar-types.txt"; @@ -72,6 +75,11 @@ import { import { ObserverCheck, ObserverTracker } from "./observers"; import { CursorPager, Pager } from "./cursor"; +const GOOGLE_DOC_TYPES_CODE = [DOCS_READ_TYPES_CODE, DOCS_TYPES_CODE].join("\n"); +const GOOGLE_DRIVE_TYPES_CODE = [ + DOCS_READ_TYPES_CODE, SHEETS_TYPES_CODE, DRIVE_TYPES_CODE, +].join("\n"); + // Vendor id = GATEKEEPER_ binding suffix (lowercased). const VENDOR_ID = "google"; const logger = obsContext.createLogger({ @@ -297,12 +305,12 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe url: "https://google.com", logo: { url: GOOGLE_LOGO_URL }, color: "#e8f0fe", - tagline: "Draft replies, edit docs, read sheets, manage calendars, and analyze data", + tagline: "Draft replies, edit docs, read sheets, search Drive, manage calendars, and analyze data", description: "Connect your Google account to give Cloudflare OS access to Gmail, Google Docs, Google " + - "Sheets, Google Calendar, and BigQuery. Build agents that triage email, draft and edit " + - "documents, read spreadsheets, find focus time, schedule meetings, or run analytics " + - "queries on your data.", + "Sheets, Google Drive, Google Calendar, and BigQuery. Build agents that triage email, " + + "draft and edit documents, read spreadsheets, search Drive and read native Docs and " + + "Sheets, find focus time, schedule meetings, or run analytics queries on your data.", providesAuth: true, }; } @@ -336,7 +344,7 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe async getTypeScriptTypes(): Promise { return [ - TYPES_CODE, DOCS_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, BIGQUERY_TYPES_CODE, + TYPES_CODE, GOOGLE_DOC_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, BIGQUERY_TYPES_CODE, ].join("\n"); } } @@ -1963,7 +1971,7 @@ export class GoogleDocGatekeeperImpl } async getTypeScriptTypes(): Promise { - return DOCS_TYPES_CODE; + return GOOGLE_DOC_TYPES_CODE; } async getAutoApprovableActions(): Promise { @@ -2983,7 +2991,7 @@ export class GoogleDriveGatekeeperImpl return { url: GOOGLE_DRIVE_RESOURCE.urlPattern, title: "Google Drive Account", - snippet: "Find files and folders in My Drive or Shared with me (metadata only)", + snippet: "Find files and folders and read native Google Docs and Sheets in My Drive or Shared with me", suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; @@ -2993,7 +3001,7 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.driveId)}`, title: drive.name, - snippet: `Find files and folders in organization-owned shared drive "${drive.name}" (metadata only)`, + snippet: `Find files and folders and read native Google Docs and Sheets in organization-owned shared drive "${drive.name}"`, suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; @@ -3002,14 +3010,14 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/file/d/${encodeURIComponent(scope.fileId)}/view`, title: file.name, - snippet: `Read metadata for Drive file "${file.name}"`, + snippet: `Read metadata and, when native, Google Doc or Sheet content from Drive file "${file.name}"`, suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; } async getTypeScriptTypes(): Promise { - return DRIVE_TYPES_CODE; + return GOOGLE_DRIVE_TYPES_CODE; } async getAutoApprovableActions() { @@ -3020,8 +3028,11 @@ export class GoogleDriveGatekeeperImpl let prepareObservation = this.ctx.props.scope.kind === "file" ? undefined : (fileIds: string[]) => this.#observerTracker().prepareObservation(fileIds); + let getDriveAccessToken = (opts?: AccessTokenRequest) => this.#getAccessToken(opts); return new GoogleDriveSessionImpl( - new DriveApi(opts => this.#getAccessToken(opts)), + new DriveApi(getDriveAccessToken), + new GoogleDocsApi(getDriveAccessToken), + new GoogleSheetsApi(getDriveAccessToken), this.ctx.props.scope, approvalQueue.dup(), prepareObservation, @@ -3075,24 +3086,86 @@ export class GoogleDriveGatekeeperImpl } @validateRpc() -class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { +class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession { + #docsApi: GoogleDocsApi; + #driveApi: DriveApi; + #documentId: string; + #approvalQueue: RpcStub; + + constructor( + docsApi: GoogleDocsApi, + driveApi: DriveApi, + documentId: string, + approvalQueue: RpcStub, + ) { + super(); + this.#docsApi = docsApi; + this.#driveApi = driveApi; + this.#documentId = documentId; + this.#approvalQueue = approvalQueue; + } + + [Symbol.dispose](): void { + this.#approvalQueue[Symbol.dispose](); + } + + 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"); + } + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc metadata", + description: "Read the current title and modification time of the Drive document.", + }); + return { title: file.name, lastModified }; + } + + async getContent(): Promise { + let snapshot = docToMarkdown(await this.#docsApi.getDocument(this.#documentId)); + await this.#approvalQueue.authorizeObservation({ + title: "Read Google Doc content", + description: "Read the current document body as Markdown.", + }); + return snapshot.markdown; + } +} + +/** Drive RPC session implementation, exported for workerd contract coverage. */ +@validateRpc() +export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { #core: DriveSessionCore; + #driveApi: DriveApi; + #docsApi: GoogleDocsApi; + #sheetsApi: GoogleSheetsApi; + #approvalQueue: RpcStub; constructor( - api: DriveApi, + driveApi: DriveApi, + docsApi: GoogleDocsApi, + sheetsApi: GoogleSheetsApi, scope: DriveBindingScope, approvalQueue: RpcStub, prepareObservation?: (fileIds: string[]) => Promise>, ) { super(); + this.#driveApi = driveApi; + this.#docsApi = docsApi; + this.#sheetsApi = sheetsApi; + this.#approvalQueue = approvalQueue; this.#core = new DriveSessionCore({ - api, + api: driveApi, scope, prepareObservation, - authorize: description => approvalQueue.authorizeObservation(description), + authorize: description => this.#approvalQueue.authorizeObservation(description), }); } + [Symbol.dispose](): void { + this.#approvalQueue[Symbol.dispose](); + } + getScope() { return this.#core.getScope(); } @@ -3108,6 +3181,24 @@ class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { getEntry(fileId: string): Promise { return this.#core.getEntry(fileId); } + + async openGoogleDoc(fileId: string): Promise { + let documentId = await this.#core.openNativeFile( + fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", + ); + return new GoogleDocReadSessionImpl( + this.#docsApi, this.#driveApi, documentId, this.#approvalQueue.dup(), + ); + } + + async openGoogleSheet(fileId: string): Promise { + let spreadsheetId = await this.#core.openNativeFile( + fileId, GOOGLE_SHEET_MIME_TYPE, "Google Sheet", + ); + return new GoogleSpreadsheetSessionImpl( + this.#sheetsApi, spreadsheetId, this.#approvalQueue.dup(), + ); + } } // ======================================================================================= diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index f6fdf19a4..920d4df9d 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -70,27 +70,27 @@ export const BIGQUERY_RESOURCE: SupportedResource = { grantable: true, }; -/** Metadata for files and folders visible to the connected Google Drive account. */ +/** Files, folders, and read-only native content visible to the connected Google Drive account. */ export const GOOGLE_DRIVE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/drive/my-drive", title: "Google Drive Account", - description: "Find files and folders in your My Drive or Shared with me.", + description: "Find files and folders, and read native Google Docs and Sheets, in My Drive or Shared with me.", grantable: true, }; -/** Metadata across one Google Workspace shared drive, keyed by its immutable drive ID. */ +/** Files, folders, and read-only native content in one Google Workspace shared drive. */ export const GOOGLE_SHARED_DRIVE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/drive/folders/:driveId", title: "Google Workspace Shared Drive", - description: "Find files and folders in one organization-owned shared drive.", + description: "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", grantable: true, }; -/** Metadata for one immutable Drive file ID. */ +/** Metadata and, when native, read-only content for one immutable Drive file ID. */ export const GOOGLE_DRIVE_FILE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/file/d/:fileId/view", title: "Google Drive File", - description: "Read metadata for one Drive file.", + description: "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", grantable: true, }; @@ -141,7 +141,11 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_DRIVE_RESOURCE, - scopes: ["https://www.googleapis.com/auth/drive.metadata.readonly"], + scopes: [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ], }, { resource: GOOGLE_SHARED_DRIVE_RESOURCE, @@ -149,7 +153,11 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_DRIVE_FILE_RESOURCE, - scopes: ["https://www.googleapis.com/auth/drive.metadata.readonly"], + scopes: [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ], }, { resource: BIGQUERY_RESOURCE, diff --git a/packages/gatekeeper-google/src/sheets-api.ts b/packages/gatekeeper-google/src/sheets-api.ts index 403c585aa..d9cca0e62 100644 --- a/packages/gatekeeper-google/src/sheets-api.ts +++ b/packages/gatekeeper-google/src/sheets-api.ts @@ -2,6 +2,7 @@ import type { SpreadsheetCellValue, SpreadsheetInfo, SpreadsheetRange, SpreadsheetValueMode, } from "./sheets-types"; import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; +import { readGoogleJson } from "./google-response"; const API_BASE = "https://sheets.googleapis.com/v4/spreadsheets"; const MAX_RANGES = 20; @@ -9,13 +10,8 @@ const MAX_TOTAL_CELLS = 50_000; const MAX_RANGE_LENGTH = 500; // Bound the encoded JSON before decoding and parsing. const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; -const MAX_ERROR_RESPONSE_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 30_000; -type GoogleErrorResponse = { - error?: { message?: string }; -}; - type RestSpreadsheet = { spreadsheetId: string; properties?: { title?: string; locale?: string; timeZone?: string }; @@ -122,83 +118,16 @@ function normalizeRange(rest: RestValueRange, requested: ValidatedRange): Spread }); return { range: rest.range ?? requested.range, values }; } - -async function readResponseText(response: Response, maxBytes: number): Promise { - let contentLength = response.headers.get("Content-Length"); - if (contentLength !== null) { - let declaredBytes = Number(contentLength); - if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { - await response.body?.cancel().catch(() => {}); - throw new Error(`Google Sheets response exceeded the ${maxBytes}-byte limit.`); - } - } - - if (!response.body) return ""; - let reader = response.body.getReader(); - let chunks: Uint8Array[] = []; - let totalBytes = 0; - try { - while (true) { - let { done, value } = await reader.read(); - if (done) break; - if (totalBytes + value.byteLength > maxBytes) { - await reader.cancel().catch(() => {}); - throw new Error(`Google Sheets response exceeded the ${maxBytes}-byte limit.`); - } - chunks.push(value); - totalBytes += value.byteLength; - } - } finally { - reader.releaseLock(); - } - - let bytes = new Uint8Array(totalBytes); - let offset = 0; - for (let chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return new TextDecoder().decode(bytes); -} - export class GoogleSheetsApi { constructor(private getAccessToken: AccessTokenProvider) {} - async #request(url: URL): Promise { + async #request(url: URL, operation: string): Promise { let response = await fetchWithAuthRetry( url.toString(), {}, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, ); - - let text: string; - try { - text = await readResponseText( - response, response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_RESPONSE_BYTES, - ); - } catch (error) { - if (!response.ok) { - throw new Error( - `Google Sheets request failed [http=${response.status}]`, { cause: error }, - ); - } - throw error; - } - - let body: unknown; - try { - body = JSON.parse(text); - } catch { - if (!response.ok) { - throw new Error(`Google Sheets request failed [http=${response.status}]`); - } - throw new Error("Google Sheets returned an invalid JSON response."); - } - - if (!response.ok) { - let errorBody = body as GoogleErrorResponse; - let detail = errorBody?.error?.message ? `: ${errorBody.error.message}` : ""; - throw new Error(`Google Sheets request failed [http=${response.status}]${detail}`); - } - return body as T; + return readGoogleJson(response, { + provider: "Google Sheets", operation, maxBytes: MAX_RESPONSE_BYTES, + }); } async getSpreadsheet(spreadsheetId: string): Promise { @@ -208,7 +137,7 @@ export class GoogleSheetsApi { "spreadsheetId,properties(title,locale,timeZone)," + "sheets(properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)))", ); - let result = await this.#request(url); + let result = await this.#request(url, "get spreadsheet"); return { id: result.spreadsheetId, title: result.properties?.title ?? "Untitled spreadsheet", @@ -241,7 +170,10 @@ export class GoogleSheetsApi { url.searchParams.set("valueRenderOption", valueRenderOption(valueMode)); if (valueMode === "raw") url.searchParams.set("dateTimeRenderOption", "SERIAL_NUMBER"); - let result = await this.#request<{ valueRanges?: RestValueRange[] }>(url); + let result = await this.#request<{ valueRanges?: RestValueRange[] }>( + url, + "read ranges", + ); let returned = result.valueRanges ?? []; return validated.map((range, index) => normalizeRange(returned[index] ?? {}, range)); } diff --git a/packages/gatekeeper-google/vite.config.ts b/packages/gatekeeper-google/vite.config.ts index 32d85c99c..6df0ab3c1 100644 --- a/packages/gatekeeper-google/vite.config.ts +++ b/packages/gatekeeper-google/vite.config.ts @@ -1,3 +1,8 @@ -// Vite+ per-package settings. Shared by all gatekeepers with a configurator UI and living beside the -// builder it runs; `withTests` is that config plus the shared vitest `test` task. -export { withTests as default } from '../../scripts/gatekeeper-configurator-vite-config.js' +import gatekeeperConfiguratorConfig from "../../scripts/gatekeeper-configurator-vite-config.js"; +import { withVitestTask } from "../../scripts/vitest-task-vite-config.js"; + +/** Configurator tasks plus separate Node and workerd test passes. */ +export default withVitestTask(gatekeeperConfiguratorConfig, [ + "vitest run", + "vitest run -c vitest.worker.config.ts", +]); diff --git a/packages/gatekeeper-google/vitest.worker.config.ts b/packages/gatekeeper-google/vitest.worker.config.ts new file mode 100644 index 000000000..7f6e5eeb3 --- /dev/null +++ b/packages/gatekeeper-google/vitest.worker.config.ts @@ -0,0 +1,22 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import capnwebValidate from "capnweb-validate/vite"; +import { defineConfig } from "vitest/config"; + +/** RpcTarget and RpcStub coverage for nested Drive sessions. */ +export default defineConfig({ + plugins: [ + capnwebValidate(), + cloudflareTest({ + main: "./src/google.ts", + miniflare: { + // Kept in step with wrangler.jsonc; drift here tests a runtime we do not deploy. + compatibilityDate: "2026-02-02", + compatibilityFlags: ["allow_irrevocable_stub_storage", "nodejs_als"], + }, + }), + ], + test: { + include: ["__tests__/workerd/*.test.ts"], + setupFiles: ["../../scripts/assert-workerd.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 325914f63..2c6f5cb5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,12 @@ importers: specifier: ^2.7.6 version: 2.7.6 devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 'catalog:' + version: 0.20.3(@cloudflare/workers-types@5.20260808.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + miniflare: + specifier: 5.20260801.0-alpha + version: 5.20260801.0-alpha typescript: specifier: 'catalog:' version: 7.0.2 From 2f03ae2611acdb7c4a5c417f63912431780cab8a Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 11:57:12 -0500 Subject: [PATCH 2/6] Harden Google native session types and diagnostics --- .../__tests__/native-api.test.ts | 55 ++++++++++++++ .../gatekeeper-google/__tests__/types.test.ts | 55 ++++++++++++-- .../__tests__/workerd/native-sessions.test.ts | 28 +++++-- packages/gatekeeper-google/package.json | 1 + packages/gatekeeper-google/src/docs-types.txt | 40 +++++++++- .../gatekeeper-google/src/google-response.ts | 74 ++++++++++++++++++- .../gatekeeper-google/src/observability.ts | 6 ++ packages/gatekeeper-google/src/resources.ts | 1 + pnpm-lock.yaml | 3 + 9 files changed, 247 insertions(+), 16 deletions(-) mode change 120000 => 100644 packages/gatekeeper-google/src/docs-types.txt diff --git a/packages/gatekeeper-google/__tests__/native-api.test.ts b/packages/gatekeeper-google/__tests__/native-api.test.ts index a0c2e6aca..de5c0b1f5 100644 --- a/packages/gatekeeper-google/__tests__/native-api.test.ts +++ b/packages/gatekeeper-google/__tests__/native-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { GoogleDocsApi } from "../src/docs-api"; import { GoogleSheetsApi } from "../src/sheets-api"; +import { readGoogleJson } from "../src/google-response"; const token = async () => "access-token"; @@ -34,12 +35,66 @@ function oversizedResponse(cancel: () => void): Response { }); } +function chunkedResponse(cancel: () => void): Response { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.enqueue(new Uint8Array([3, 4])); + }, + cancel, + }); + return new Response(body); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("native Google content API safety", () => { + it("cancels an unknown-length response once streamed bytes exceed the limit", async () => { + const cancel = vi.fn(); + + await expect(readGoogleJson(chunkedResponse(cancel), { + provider: "Google Test", operation: "read", maxBytes: 3, + })).rejects.toThrow(/response exceeded/); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("logs bounded provider diagnostics without provider prose", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const response = Response.json({ + error: { + code: 403, + status: "PERMISSION_DENIED", + message: "secret provider response prose", + errors: [{ reason: "accessNotConfigured", message: "secret nested prose" }], + details: [ + { reason: "SERVICE_DISABLED" }, + { reason: "unsafe provider reason prose" }, + ], + }, + }, { status: 403 }); + + await expect(readGoogleJson(response, { + provider: "Google Sheets", operation: "get spreadsheet", maxBytes: 1024, + })).rejects.toThrow("Google Sheets get spreadsheet failed [http=403]"); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toMatchObject({ + component: "gatekeeper.google.api", + event: "google.api.request.failed", + httpStatus: 403, + operation: "get spreadsheet", + provider: "Google Sheets", + providerCode: 403, + providerReasons: ["accessNotConfigured", "SERVICE_DISABLED"], + providerStatus: "PERMISSION_DENIED", + vendorId: "google", + }); + expect(JSON.stringify(warn.mock.calls)).not.toContain("secret provider"); + expect(JSON.stringify(warn.mock.calls)).not.toContain("unsafe provider reason prose"); + }); it.each([ ["Docs", () => new GoogleDocsApi(token).getDocument("doc-1"), docBody()], ["Sheets", () => new GoogleSheetsApi(token).getSpreadsheet("sheet-1"), sheetBody()], diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts index dafa732a1..a3a414de6 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -4,6 +4,7 @@ import { lstatSync, readFileSync, readlinkSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import ts from "typescript6"; const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src"); @@ -15,15 +16,53 @@ function source(name: string): string { return readFileSync(sourcePath(name), "utf8"); } +function compileAgentTypes(sourceText: string): string[] { + const fileName = "/agent-types.ts"; + const options: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + }; + const baseHost = ts.createCompilerHost(options); + const host: ts.CompilerHost = { + ...baseHost, + fileExists: name => name === fileName || baseHost.fileExists(name), + getSourceFile: (name, languageVersion, onError, shouldCreateNewSourceFile) => + name === fileName + ? ts.createSourceFile(name, sourceText, languageVersion, true) + : baseHost.getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile), + readFile: name => name === fileName ? sourceText : baseHost.readFile(name), + }; + const program = ts.createProgram([fileName], options, host); + return ts.getPreEmitDiagnostics(program).map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} + describe("embedded agent declarations", () => { - for (const name of ["docs-read-types", "docs-types"]) { - it(`keeps ${name}.txt linked to its TypeScript declaration`, () => { - const textUrl = sourcePath(`${name}.txt`); - expect(lstatSync(textUrl).isSymbolicLink()).toBe(true); - expect(readlinkSync(textUrl)).toBe(`${name}.d.ts`); - expect(source(`${name}.txt`)).toBe(source(`${name}.d.ts`)); - }); - } + it("keeps docs-read-types.txt linked to its TypeScript declaration", () => { + const textUrl = sourcePath("docs-read-types.txt"); + expect(lstatSync(textUrl).isSymbolicLink()).toBe(true); + expect(readlinkSync(textUrl)).toBe("docs-read-types.d.ts"); + expect(source("docs-read-types.txt")).toBe(source("docs-read-types.d.ts")); + }); + + it("keeps the Google Doc declaration aligned after module-only imports and exports", () => { + const modulePrefix = + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + + 'export type { DocMetadata, GoogleDocReadSession } from "./docs-read-types";\n\n'; + const docsTypes = source("docs-types.d.ts"); + expect(docsTypes.startsWith(modulePrefix)).toBe(true); + expect(source("docs-types.txt")).toBe(docsTypes.slice(modulePrefix.length)); + }); + + it("compiles the exact Google Doc agent declaration bundle without module dependencies", () => { + const types = [source("docs-read-types.txt"), source("docs-types.txt")].join("\n"); + + expect(compileAgentTypes(types)).toEqual([]); + }); it("keeps the Drive declaration aligned after module-only imports", () => { const modulePrefix = diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index 2b46ffe2f..d3e1ba464 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -1,5 +1,7 @@ import { RpcStub, RpcTarget } from "cloudflare:workers"; -import type { ApprovalQueue, ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; +import type { + ActionDescription, ApprovalQueue, HookController, HookDescription, ObservationDescription, +} from "@gadgets/workshop-shared/gatekeeper"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GoogleDocsApi } from "../../src/docs-api"; import { DriveApi } from "../../src/drive-api"; @@ -8,17 +10,29 @@ 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[]; async function getAccessToken(): Promise { return "access-token"; } -class TestApprovalQueue extends RpcTarget { +class TestApprovalQueue extends RpcTarget implements ApprovalQueue { readonly observations: ObservationDescription[] = []; async authorizeObservation(description: ObservationDescription): Promise { this.observations.push(description); } + + async submitAction(_action: number, _description: ActionDescription): Promise { + throw new Error("Unexpected action submission"); + } + + async bindHook( + _controller: Fetcher>, _callback: RpcStub, + _description: HookDescription, + ): Promise { + throw new Error("Unexpected hook binding"); + } } function providerFile(id: string, mimeType: string) { @@ -56,17 +70,20 @@ function installProvider() { function newSession() { const queue = new TestApprovalQueue(); + const queueStub: RpcStub = new RpcStub(queue); const session = new GoogleDriveSessionImpl( new DriveApi(getAccessToken), new GoogleDocsApi(getAccessToken), new GoogleSheetsApi(getAccessToken), { kind: "account" }, - new RpcStub(queue) as unknown as RpcStub, + queueStub, ); return { queue, session }; } -beforeEach(() => installProvider()); +beforeEach(() => { + providerUrls = installProvider(); +}); afterEach(() => vi.unstubAllGlobals()); describe("Drive nested native sessions", () => { @@ -85,13 +102,12 @@ describe("Drive nested native sessions", () => { }); it("returns the existing Sheet target with bounded range validation", async () => { - const urls = installProvider(); const { session } = newSession(); const sheet = await session.openGoogleSheet("sheet-1"); await expect(sheet.readRange("A:A")).rejects.toThrow(/Invalid or unbounded A1 range/); - expect(urls.some(url => url.includes("sheets.googleapis.com"))).toBe(false); + expect(providerUrls.some(url => url.includes("sheets.googleapis.com"))).toBe(false); }); it("gives each child an independently disposable approval-queue stub", async () => { diff --git a/packages/gatekeeper-google/package.json b/packages/gatekeeper-google/package.json index fb043a7cd..ac6e2a2cb 100644 --- a/packages/gatekeeper-google/package.json +++ b/packages/gatekeeper-google/package.json @@ -22,6 +22,7 @@ "@cloudflare/vitest-pool-workers": "catalog:", "miniflare": "5.20260801.0-alpha", "typescript": "catalog:", + "typescript6": "npm:typescript@6.0.3", "vitest": "catalog:", "wrangler": "catalog:" } diff --git a/packages/gatekeeper-google/src/docs-types.txt b/packages/gatekeeper-google/src/docs-types.txt deleted file mode 120000 index 8a0551ea3..000000000 --- a/packages/gatekeeper-google/src/docs-types.txt +++ /dev/null @@ -1 +0,0 @@ -docs-types.d.ts \ No newline at end of file diff --git a/packages/gatekeeper-google/src/docs-types.txt b/packages/gatekeeper-google/src/docs-types.txt new file mode 100644 index 000000000..6e2e2ea2a --- /dev/null +++ b/packages/gatekeeper-google/src/docs-types.txt @@ -0,0 +1,39 @@ +/** Read/write access to one directly bound native Google Doc. */ +export interface GoogleDocSession extends GoogleDocReadSession { + + /** + * Find `oldMarkdown` in the current document content and replace it with `newMarkdown`. + * Both parameters are Markdown text. + * + * 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 + * 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 + * following Markdown features are supported in `newMarkdown`: + * - Headings (`# ` through `###### `) + * - Bold (`**text**`) + * - Italic (`*text*`) + * - Bold+italic (`***text***`) + * - Links (`[text](url)`) + * - Strikethrough (`~~text~~`) + * - Bullet lists (`- item`) + * - Numbered lists (`1. item`) + * - Plain paragraphs (separated by blank lines) + * + * Unsupported Markdown features (tables, images, code blocks, etc.) are inserted as plain text. + * + * A subsequent `getContent()` call reflects this replacement. + */ + replaceText(oldMarkdown: string, newMarkdown: string): Promise; + + /** + * Append Markdown content to the end of the document. The same Markdown features as + * `replaceText()` are supported. + */ + appendText(markdown: string): Promise; +} diff --git a/packages/gatekeeper-google/src/google-response.ts b/packages/gatekeeper-google/src/google-response.ts index a939c4b60..d593083a9 100644 --- a/packages/gatekeeper-google/src/google-response.ts +++ b/packages/gatekeeper-google/src/google-response.ts @@ -1,9 +1,77 @@ +import { obsContext } from "./observability"; + +const MAX_PROVIDER_ERROR_BYTES = 64 * 1024; +const MAX_PROVIDER_REASONS = 8; +const PROVIDER_CODE_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; +const logger = obsContext.createLogger({ + component: "gatekeeper.google.api", vendorId: "google", +}); + +type GoogleProviderDiagnostics = { + providerCode?: number; + providerStatus?: string; + providerReasons?: string[]; +}; + type GoogleJsonResponseOptions = { provider: string; operation: string; maxBytes: number; }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function safeProviderCode(value: unknown): string | undefined { + return typeof value === "string" && PROVIDER_CODE_PATTERN.test(value) ? value : undefined; +} + +function addProviderReasons(value: unknown, reasons: Set): void { + if (!Array.isArray(value)) return; + for (let entry of value) { + if (reasons.size >= MAX_PROVIDER_REASONS) return; + if (!isRecord(entry)) continue; + let reason = safeProviderCode(entry.reason); + if (reason !== undefined) reasons.add(reason); + } +} + +function parseProviderDiagnostics(text: string): GoogleProviderDiagnostics { + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return {}; + } + if (!isRecord(payload) || !isRecord(payload.error)) return {}; + + let providerError = payload.error; + let providerCode = typeof providerError.code === "number" && + Number.isSafeInteger(providerError.code) ? providerError.code : undefined; + let providerStatus = safeProviderCode(providerError.status); + let reasons = new Set(); + addProviderReasons(providerError.errors, reasons); + addProviderReasons(providerError.details, reasons); + + let diagnostics: GoogleProviderDiagnostics = {}; + if (providerCode !== undefined) diagnostics.providerCode = providerCode; + if (providerStatus !== undefined) diagnostics.providerStatus = providerStatus; + if (reasons.size > 0) diagnostics.providerReasons = [...reasons]; + return diagnostics; +} + +async function readProviderDiagnostics( + response: Response, provider: string, +): Promise { + try { + let text = await readBoundedText(response, MAX_PROVIDER_ERROR_BYTES, provider); + return parseProviderDiagnostics(text); + } catch { + return {}; + } +} + async function readBoundedText( response: Response, maxBytes: number, @@ -53,7 +121,11 @@ export async function readGoogleJson( ): Promise { let { provider, operation, maxBytes } = options; if (!response.ok) { - await response.body?.cancel().catch(() => {}); + let diagnostics = await readProviderDiagnostics(response, provider); + logger.warn("Google provider request failed", { + event: "google.api.request.failed", provider, operation, httpStatus: response.status, + ...diagnostics, + }); throw new Error(`${provider} ${operation} failed [http=${response.status}]`); } diff --git a/packages/gatekeeper-google/src/observability.ts b/packages/gatekeeper-google/src/observability.ts index dfe6690f9..d8f5cc73e 100644 --- a/packages/gatekeeper-google/src/observability.ts +++ b/packages/gatekeeper-google/src/observability.ts @@ -3,7 +3,13 @@ import { createObservabilityContext } from "@gadgets/backend-utils/observability /** Observability fields emitted by the Google gatekeeper. */ export type GoogleObservabilityFields = { actionId: number | string; + httpStatus: number; messageId: string; + operation: string; + provider: string; + providerCode: number; + providerReasons: string[]; + providerStatus: string; vendorId: string; }; diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index 920d4df9d..fa729f4f1 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -149,6 +149,7 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] }, { resource: GOOGLE_SHARED_DRIVE_RESOURCE, + // `drive.readonly` already authorizes Docs and Sheets content; do not add redundant API scopes. scopes: ["https://www.googleapis.com/auth/drive.readonly"], }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c6f5cb5c..e55aec36b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + typescript6: + specifier: npm:typescript@6.0.3 + 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)) From f809e8ba3200d91baab94cb34032263eb9d118ec Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 15:25:28 -0500 Subject: [PATCH 3/6] Add Google Drive creation API --- .../__tests__/resources.test.ts | 37 ++++++++++++---- .../gatekeeper-google/__tests__/types.test.ts | 41 +++++++++++++++++ .../gatekeeper-google/src/drive-types.d.ts | 44 ++++++++++++++++++- .../gatekeeper-google/src/drive-types.txt | 43 +++++++++++++++++- packages/gatekeeper-google/src/google.ts | 2 +- packages/gatekeeper-google/src/resources.ts | 9 +++- 6 files changed, 162 insertions(+), 14 deletions(-) diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index afcfea9c3..c949ca305 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -92,8 +92,12 @@ describe("resourceUrlPatternsToOAuthScopes", () => { "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.file", + ]], + [GOOGLE_SHARED_DRIVE_RESOURCE, [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", ]], - [GOOGLE_SHARED_DRIVE_RESOURCE, ["https://www.googleapis.com/auth/drive.readonly"]], [GOOGLE_DRIVE_FILE_RESOURCE, [ "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", @@ -105,19 +109,34 @@ describe("resourceUrlPatternsToOAuthScopes", () => { ]); }); - it("requires account and file grants to expand beyond metadata-only consent", () => { - const oldMetadataGrant = [ + it("expands old writable Drive grants without widening exact-file or direct bindings", () => { + const oldAccountGrant = [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", ]; - const granted = grantedResourcesFromScopes(oldMetadataGrant); - - expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); - expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); - expect(grantedResourcesFromScopes([ + const oldSharedDriveGrant = [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.readonly", - ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + ]; + + expect(grantedResourcesFromScopes(oldAccountGrant)) + .not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); + expect(grantedResourcesFromScopes(oldSharedDriveGrant)) + .not.toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + expect(grantedResourcesFromScopes(oldAccountGrant)) + .toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); + expect(resourceUrlPatternsToOAuthScopes([GOOGLE_DOC_RESOURCE.urlPattern])).toEqual([ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.metadata.readonly", + ]); + expect(resourceUrlPatternsToOAuthScopes([GOOGLE_SHEETS_RESOURCE.urlPattern])).toEqual([ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + ]); }); it("deduplicates scopes shared between resources", () => { let scopes = resourceUrlPatternsToOAuthScopes( diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts index a3a414de6..cd64e711d 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -66,6 +66,7 @@ describe("embedded agent declarations", () => { it("keeps the Drive declaration aligned after module-only imports", () => { const modulePrefix = + 'import type { RpcTarget } from "cloudflare:workers";\n' + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + 'import type { GoogleSpreadsheetSession } from "./sheets-types";\n\n'; const driveTypes = source("drive-types.d.ts"); @@ -93,4 +94,44 @@ describe("embedded agent declarations", () => { ); expect(driveTypes).not.toContain("GoogleDocSession>"); }); + + it("splits Drive creation authority from read-only sessions", () => { + const types = [ + "declare class RpcTarget {}", + source("docs-read-types.txt"), + source("docs-types.txt"), + source("sheets-types.d.ts"), + source("drive-types.txt"), + ` + declare const account: GoogleDriveSession; + declare const sharedDrive: GoogleDriveSession; + declare const file: GoogleDriveReadSession; + declare const nestedDoc: GoogleDocReadSession; + declare const directDoc: GoogleDocSession; + declare const sheet: GoogleSpreadsheetSession; + + account.createGoogleDoc({ name: "Quarterly plan" }); + account.createGoogleSheet({ name: "Forecast", parentId: "folder-1" }); + account.createFolder({ name: "Archive" }); + account.getCreationResult({ id: 1, kind: "googleDoc", name: "Quarterly plan" }); + sharedDrive.createGoogleDoc({ name: "Quarterly plan" }); + sharedDrive.createGoogleSheet({ name: "Forecast" }); + sharedDrive.createFolder({ name: "Archive", parentId: "folder-2" }); + sharedDrive.getCreationResult({ id: 2, kind: "folder", name: "Archive" }); + + // @ts-expect-error Exact-file Drive sessions remain read-only. + file.createGoogleDoc({ name: "Denied" }); + // @ts-expect-error Exact-file Drive sessions cannot query creation actions. + file.getCreationResult({ id: 1, kind: "googleDoc", name: "Denied" }); + // @ts-expect-error Nested Docs sessions expose no Drive creation methods. + nestedDoc.createFolder({ name: "Denied" }); + // @ts-expect-error Direct Docs bindings retain only their existing document API. + directDoc.createGoogleDoc({ name: "Denied" }); + // @ts-expect-error Nested and direct Sheets sessions expose no Drive creation methods. + sheet.createGoogleSheet({ name: "Denied" }); + `, + ].join("\n"); + + expect(compileAgentTypes(types)).toEqual([]); + }); }); diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index 594439ffd..22b9383a5 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -1,3 +1,4 @@ +import type { RpcTarget } from "cloudflare:workers"; import type { GoogleDocReadSession } from "./docs-read-types"; import type { GoogleSpreadsheetSession } from "./sheets-types"; @@ -109,12 +110,41 @@ export type DriveSearchQuery = { order?: DriveOrder; }; +/** The native Drive item kind supported by creation methods. */ +export type DriveCreationKind = "googleDoc" | "googleSheet" | "folder"; + +/** Options for creating a blank native Drive item. */ +export interface DriveCreationOptions { + /** Non-empty name for the new item. */ + name: string; + /** Destination folder ID; defaults to My Drive root or the bound shared-drive root. */ + parentId?: string; +} + +/** Reference used to query the outcome of an asynchronous Drive creation. */ +export interface DriveCreationHandle { + /** Sequential action identifier within this binding. */ + id: number; + /** Requested item kind. */ + kind: DriveCreationKind; + /** Requested item name. */ + name: string; +} + +/** Current outcome of a Drive creation request. */ +export type DriveCreationOutcome = + | { status: "pending" } + | { status: "rejected" } + | { status: "failed"; message: string } + | { status: "reverted" } + | { status: "created"; kind: DriveCreationKind; entry: DriveEntry }; + /** * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. * * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ -export interface GoogleDriveSession { +export interface GoogleDriveReadSession extends RpcTarget { /** Return the immutable binding scope with current display metadata. */ getScope(): Promise; @@ -151,3 +181,15 @@ export interface GoogleDriveSession { */ openGoogleSheet(fileId: string): Promise; } + +/** Drive account or shared-drive access, including blank native item creation. */ +export interface GoogleDriveSession extends GoogleDriveReadSession { + /** Queue creation of a blank native Google Doc. */ + createGoogleDoc(options: DriveCreationOptions): Promise; + /** Queue creation of a blank native Google Sheet. */ + createGoogleSheet(options: DriveCreationOptions): Promise; + /** Queue creation of a folder. */ + createFolder(options: DriveCreationOptions): Promise; + /** Read the current outcome for a previously returned handle. */ + getCreationResult(handle: DriveCreationHandle): Promise; +} diff --git a/packages/gatekeeper-google/src/drive-types.txt b/packages/gatekeeper-google/src/drive-types.txt index 5cef69d45..38e967b8d 100644 --- a/packages/gatekeeper-google/src/drive-types.txt +++ b/packages/gatekeeper-google/src/drive-types.txt @@ -106,12 +106,41 @@ export type DriveSearchQuery = { order?: DriveOrder; }; +/** The native Drive item kind supported by creation methods. */ +export type DriveCreationKind = "googleDoc" | "googleSheet" | "folder"; + +/** Options for creating a blank native Drive item. */ +export interface DriveCreationOptions { + /** Non-empty name for the new item. */ + name: string; + /** Destination folder ID; defaults to My Drive root or the bound shared-drive root. */ + parentId?: string; +} + +/** Reference used to query the outcome of an asynchronous Drive creation. */ +export interface DriveCreationHandle { + /** Sequential action identifier within this binding. */ + id: number; + /** Requested item kind. */ + kind: DriveCreationKind; + /** Requested item name. */ + name: string; +} + +/** Current outcome of a Drive creation request. */ +export type DriveCreationOutcome = + | { status: "pending" } + | { status: "rejected" } + | { status: "failed"; message: string } + | { status: "reverted" } + | { status: "created"; kind: DriveCreationKind; entry: DriveEntry }; + /** * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. * * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ -export interface GoogleDriveSession { +export interface GoogleDriveReadSession extends RpcTarget { /** Return the immutable binding scope with current display metadata. */ getScope(): Promise; @@ -148,3 +177,15 @@ export interface GoogleDriveSession { */ openGoogleSheet(fileId: string): Promise; } + +/** Drive account or shared-drive access, including blank native item creation. */ +export interface GoogleDriveSession extends GoogleDriveReadSession { + /** Queue creation of a blank native Google Doc. */ + createGoogleDoc(options: DriveCreationOptions): Promise; + /** Queue creation of a blank native Google Sheet. */ + createGoogleSheet(options: DriveCreationOptions): Promise; + /** Queue creation of a folder. */ + createFolder(options: DriveCreationOptions): Promise; + /** Read the current outcome for a previously returned handle. */ + getCreationResult(handle: DriveCreationHandle): Promise; +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 5152903a1..6866722df 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -3012,7 +3012,7 @@ export class GoogleDriveGatekeeperImpl title: file.name, snippet: `Read metadata and, when native, Google Doc or Sheet content from Drive file "${file.name}"`, suggestedBindingName: "GOOGLE_DRIVE", - tsType: "GoogleDriveSession", + tsType: "GoogleDriveReadSession", }; } diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index fa729f4f1..e4649acc6 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -145,12 +145,17 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.file", ], }, { resource: GOOGLE_SHARED_DRIVE_RESOURCE, - // `drive.readonly` already authorizes Docs and Sheets content; do not add redundant API scopes. - scopes: ["https://www.googleapis.com/auth/drive.readonly"], + scopes: [ + // `drive.readonly` already authorizes Docs and Sheets content. + "https://www.googleapis.com/auth/drive.readonly", + // Limit writes to files this app creates or the user explicitly opens with it. + "https://www.googleapis.com/auth/drive.file", + ], }, { resource: GOOGLE_DRIVE_FILE_RESOURCE, From 7034acb722df6872987012f39ecfec8a176de35e Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 15:33:24 -0500 Subject: [PATCH 4/6] Add Drive creation transport and scope checks --- .../__tests__/drive-api.test.ts | 169 ++++++++++++++++- .../__tests__/drive-session.test.ts | 150 +++++++++++++++ packages/gatekeeper-google/src/drive-api.ts | 177 ++++++++++++++++-- .../gatekeeper-google/src/drive-session.ts | 71 +++++-- 4 files changed, 534 insertions(+), 33 deletions(-) diff --git a/packages/gatekeeper-google/__tests__/drive-api.test.ts b/packages/gatekeeper-google/__tests__/drive-api.test.ts index ee72614e5..6dd321871 100644 --- a/packages/gatekeeper-google/__tests__/drive-api.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-api.test.ts @@ -38,6 +38,14 @@ const jsonResponse = (body: unknown, status = 200) => const api = (token = "tok") => new DriveApi(async () => token); +const CREATION_REQUEST_ID = "123e4567-e89b-42d3-a456-426614174000"; +const DRIVE_FILE_ITEM_FIELDS = [ + "id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId", + "owners(displayName,emailAddress)", "webViewLink", + "shortcutDetails(targetId,targetMimeType)", "trashed", + "capabilities(canAddChildren,canTrash)", +].join(","); + function batchResponse(results: { status: number; body?: string }[]): Response { let boundary = "drive_test_boundary"; let body = results.map((result, index) => [ @@ -53,7 +61,10 @@ function batchResponse(results: { status: number; body?: string }[]): Response { return new Response(body, { headers: { "Content-Type": `multipart/mixed; boundary=${boundary}` } }); } -afterEach(() => { vi.unstubAllGlobals(); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe("escapeDriveQueryLiteral", () => { it("leaves an ordinary value alone", () => { @@ -247,6 +258,162 @@ describe("metadata lookup", () => { }); }); +describe("creation mutations", () => { + it.each([ + ["Google Doc", "application/vnd.google-apps.document"], + ["Google Sheet", "application/vnd.google-apps.spreadsheet"], + ["folder", "application/vnd.google-apps.folder"], + ] as const)("creates a metadata-only %s in one resolved parent", async (name, mimeType) => { + let created = { + id: `created-${name}`, name, mimeType, parents: ["parent-1"], trashed: false, + capabilities: { canAddChildren: mimeType.endsWith("folder"), canTrash: true }, + }; + let calls = stubFetch([jsonResponse(created)]); + + await expect(api().createFile({ + name, mimeType, parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).resolves.toEqual(created); + + expect(calls).toHaveLength(1); + expect(calls[0].url.pathname).toBe("/drive/v3/files"); + expect(calls[0].method).toBe("POST"); + expect(calls[0].headers.get("Content-Type")).toBe("application/json"); + expect(calls[0].url.searchParams.get("supportsAllDrives")).toBe("true"); + expect(calls[0].url.searchParams.get("ignoreDefaultVisibility")).toBe("true"); + expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS); + expect(JSON.parse(calls[0].body ?? "")).toEqual({ + name, + mimeType, + parents: ["parent-1"], + appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID }, + }); + expect(calls[0].body).not.toContain("driveId"); + }); + + it("uses a finite timeout for create requests", async () => { + let timeout = vi.spyOn(AbortSignal, "timeout"); + stubFetch([jsonResponse({ id: "created-1", name: "Plan" })]); + + await api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + }); + + expect(timeout).toHaveBeenCalledWith(30_000); + }); + + it("refreshes once on a create 401 and replays the exact metadata body", async () => { + let drive = new DriveApi(async opts => opts?.forceRefresh ? "fresh" : "stale"); + let calls = stubFetch([ + new Response("expired", { status: 401 }), + jsonResponse({ id: "created-1", name: "Plan" }), + ]); + + await drive.createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + }); + + expect(calls.map(call => call.headers.get("Authorization"))) + .toEqual(["Bearer stale", "Bearer fresh"]); + expect(calls[0].body).toBe(calls[1].body); + }); + + it("rejects malformed create metadata instead of trusting it", async () => { + stubFetch([jsonResponse({ id: 42, name: "Plan" })]); + + await expect(api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).rejects.toThrow("Invalid Google Drive file response"); + }); + + it("rejects malformed JSON without exposing its contents", async () => { + stubFetch([new Response("not-json-with-secret-prose")]); + + await expect(api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).rejects.toThrow("Invalid Google Drive JSON response"); + }); + + it("bounds a create response before parsing it", async () => { + let pulls = 0; + let cancelled = false; + let body = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls <= 3) controller.enqueue(new Uint8Array(3_000_000)); + else controller.close(); + }, + cancel() { cancelled = true; }, + }); + stubFetch([new Response(body)]); + + await expect(api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).rejects.toThrow("Google Drive response was too large"); + expect(cancelled).toBe(true); + expect(pulls).toBeLessThan(4); + }); + + it("finds one prior create only through its private generated marker", async () => { + let found = { + id: "created-1", name: "Plan", mimeType: "application/vnd.google-apps.document", + parents: ["parent-1"], trashed: false, capabilities: { canTrash: true }, + }; + let calls = stubFetch([jsonResponse({ files: [found] })]); + + await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)).resolves.toEqual(found); + + let params = calls[0].url.searchParams; + expect(calls[0].method).toBeUndefined(); + expect(params.get("q")).toBe( + `appProperties has { key='gadgetsCreationRequestId' and value='${CREATION_REQUEST_ID}' }`, + ); + expect(params.get("pageSize")).toBe("2"); + expect(params.get("spaces")).toBe("drive"); + expect(params.get("supportsAllDrives")).toBe("true"); + expect(params.get("includeItemsFromAllDrives")).toBe("true"); + expect(params.get("fields")).toBe(`nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`); + }); + + it("returns no prior create when the generated marker is absent", async () => { + stubFetch([jsonResponse({ files: [] })]); + await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)) + .resolves.toBeUndefined(); + }); + + it("fails closed when more than one file has the generated marker", async () => { + stubFetch([jsonResponse({ + files: [{ id: "created-1", name: "Plan" }, { id: "created-2", name: "Plan" }], + })]); + + await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)) + .rejects.toThrow("Multiple Google Drive files matched one creation request"); + }); + + it("rejects a non-generated marker before issuing a query", async () => { + let calls = stubFetch([]); + await expect(api().findFileByCreationRequestId("x' or trashed = false")) + .rejects.toThrow("Invalid Google Drive creation request ID"); + expect(calls).toEqual([]); + }); + + it("trashes with a metadata-only shared-drive PATCH", async () => { + let calls = stubFetch([jsonResponse({ id: "created/1", name: "Plan", trashed: true })]); + + await expect(api().trashFile("created/1")).resolves.toBeUndefined(); + + expect(calls[0].url.pathname).toBe("/drive/v3/files/created%2F1"); + expect(calls[0].method).toBe("PATCH"); + expect(calls[0].url.searchParams.get("supportsAllDrives")).toBe("true"); + expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS); + expect(JSON.parse(calls[0].body ?? "")).toEqual({ trashed: true }); + }); +}); + describe("bulk access verification", () => { it("maps fresh files.get outcomes back to the requested ID order", async () => { let calls = stubFetch([batchResponse([ diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index e5f6e402b..d7a1c71d3 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -4,6 +4,8 @@ import { DriveSessionCore, driveFileToEntry } from "../src/drive-session"; import type { ObserverCheck } from "../src/observers"; import type { DriveFile } from "../src/drive-api"; +const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + const file = (overrides: Partial = {}): DriveFile => ({ id: "file-1", name: "Quarterly plan", @@ -154,6 +156,154 @@ describe("Drive session scope", () => { }); }); +describe("Drive creation parent authorization", () => { + it("resolves the account root alias to its canonical ID before authorizing it", async () => { + let events: string[] = []; + let { session, getFile } = core({ + getFile: async id => { + events.push(`fetch:${id}`); + return file({ + id: "root-id", name: "My Drive", mimeType: FOLDER_MIME_TYPE, + capabilities: { canAddChildren: true }, + }); + }, + prepareObservation: async ids => { + events.push(`prepare:${ids.join(",")}`); + return { pendingSets: ids, commit: () => events.push("commit") }; + }, + authorize: async () => { events.push("authorize"); }, + }); + + await expect(session.resolveCreationParent()).resolves.toEqual({ + id: "root-id", name: "My Drive", + }); + expect(getFile).toHaveBeenCalledWith("root"); + expect(events).toEqual(["fetch:root", "prepare:root-id", "authorize", "commit"]); + }); + + it("uses and fetches the bound shared-drive root by default", async () => { + let { session, getFile, prepared } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async id => file({ + id, name: "Team Drive", mimeType: FOLDER_MIME_TYPE, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent()).resolves.toEqual({ + id: "drive-1", name: "Team Drive", + }); + expect(getFile).toHaveBeenCalledWith("drive-1"); + expect(prepared).toEqual([["drive-1"]]); + }); + + it("fetches an explicit nested folder ID exactly", async () => { + let { session, getFile } = core({ + getFile: async id => file({ + id, name: "Nested", mimeType: FOLDER_MIME_TYPE, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent(" folder-with-spaces ")) + .resolves.toEqual({ id: " folder-with-spaces ", name: "Nested" }); + expect(getFile).toHaveBeenCalledWith(" folder-with-spaces "); + }); + + it("rejects an empty explicit parent ID before provider access", async () => { + let { session, getFile } = core(); + await expect(session.resolveCreationParent(" ")) + .rejects.toThrow("parentId must not be empty"); + expect(getFile).not.toHaveBeenCalled(); + }); + + it("rejects exact-file creation authority before provider access", async () => { + let { session, getFile } = core({ scope: { kind: "file", fileId: "folder-1" } }); + await expect(session.resolveCreationParent("folder-1")) + .rejects.toThrow(/outside this Drive binding/); + expect(getFile).not.toHaveBeenCalled(); + }); + + it.each([ + ["ordinary file", "application/pdf"], + ["shortcut", "application/vnd.google-apps.shortcut"], + ])("rejects a %s as a creation destination", async (_kind, mimeType) => { + let { session, prepared, authorizations } = core({ + getFile: async id => file({ + id, mimeType, capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent("file-1")) + .rejects.toThrow("Drive creation parent must identify a folder"); + expect(prepared).toEqual([]); + expect(authorizations).toEqual([]); + }); + + it.each([undefined, false])( + "rejects a folder whose canAddChildren capability is %s", + async canAddChildren => { + let { session, prepared } = core({ + getFile: async id => file({ + id, mimeType: FOLDER_MIME_TYPE, + capabilities: canAddChildren === undefined ? {} : { canAddChildren }, + }), + }); + + await expect(session.resolveCreationParent("folder-1")) + .rejects.toThrow("Drive creation parent does not allow adding children"); + expect(prepared).toEqual([]); + }, + ); + + it("rejects a folder from another shared drive before observation", async () => { + let { session, prepared } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async id => file({ + id, driveId: "drive-2", mimeType: FOLDER_MIME_TYPE, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent("folder-1")) + .rejects.toThrow(/outside this Drive binding/); + expect(prepared).toEqual([]); + }); + + it("fails revalidation when an approved parent moves outside the binding", async () => { + let request = 0; + let { session } = core({ + scope: { kind: "sharedDrive", driveId: "drive-1" }, + getFile: async id => file({ + id, driveId: request++ === 0 ? "drive-1" : "drive-2", + mimeType: FOLDER_MIME_TYPE, capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent("folder-1")) + .resolves.toEqual({ id: "folder-1", name: "Quarterly plan" }); + await expect(session.revalidateCreationParent("folder-1")) + .rejects.toThrow(/outside this Drive binding/); + }); + + it("does not commit the parent observation when authorization fails", async () => { + let committed = false; + let { session } = core({ + getFile: async id => file({ + id, mimeType: FOLDER_MIME_TYPE, capabilities: { canAddChildren: true }, + }), + prepareObservation: async ids => ({ + pendingSets: ids, commit: () => { committed = true; }, + }), + authorize: async () => { throw new Error("denied"); }, + }); + + await expect(session.resolveCreationParent("folder-1")) + .rejects.toThrow("denied"); + expect(committed).toBe(false); + }); +}); + describe("Drive native sessions", () => { const docMime = "application/vnd.google-apps.document"; const sheetMime = "application/vnd.google-apps.spreadsheet"; diff --git a/packages/gatekeeper-google/src/drive-api.ts b/packages/gatekeeper-google/src/drive-api.ts index eeb607592..72219c11f 100644 --- a/packages/gatekeeper-google/src/drive-api.ts +++ b/packages/gatekeeper-google/src/drive-api.ts @@ -1,12 +1,20 @@ // Structured Google Drive API client shared by configurators, sessions, and observer verification. import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; +import { obsContext } from "./observability"; const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"; const DRIVE_BATCH_URL = "https://www.googleapis.com/batch/drive/v3"; const MAX_BATCH_FILES = 100; const MAX_BATCH_RESPONSE_BYTES = 1_000_000; const MAX_JSON_RESPONSE_BYTES = 5_000_000; +const DRIVE_API_TIMEOUT_MS = 30_000; +const CREATION_REQUEST_PROPERTY = "gadgetsCreationRequestId"; +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const logger = obsContext.createLogger({ + component: "gatekeeper.google.drive-api", vendorId: "google", +}); /** The subset of Drive's file resource this gatekeeper asks for. */ export type DriveFile = { @@ -20,6 +28,8 @@ export type DriveFile = { owners?: { displayName?: string; emailAddress?: string }[]; webViewLink?: string; shortcutDetails?: { targetId?: string; targetMimeType?: string }; + trashed?: boolean; + capabilities?: { canAddChildren?: boolean; canTrash?: boolean }; }; /** Current metadata for one shared drive. */ @@ -28,7 +38,8 @@ export type DriveInfo = { id: string; name: string }; const DRIVE_FILE_ITEM_FIELDS = [ "id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId", "owners(displayName,emailAddress)", "webViewLink", - "shortcutDetails(targetId,targetMimeType)", + "shortcutDetails(targetId,targetMimeType)", "trashed", + "capabilities(canAddChildren,canTrash)", ].join(","); /** Drive returns only requested fields, so this mask and {@link DriveFile} travel together. */ @@ -61,6 +72,18 @@ export type DriveFileList = { files: DriveFile[]; nextPageToken?: string }; export type DriveListDrivesOptions = { pageSize?: number; pageToken?: string; nameContains?: string }; export type DriveList = { drives: DriveInfo[]; nextPageToken?: string }; +/** Metadata-only native Drive file creation parameters. */ +export type DriveCreateFileOptions = { + /** Name sent to Drive without display sanitization. */ + name: string; + /** Native Google MIME type for the new item. */ + mimeType: string; + /** Already-authorized immutable destination folder ID. */ + parentId: string; + /** Gatekeeper-generated UUID used as a private idempotency marker. */ + requestId: string; +}; + /** Drive refused because the API is not enabled on this OAuth project. */ export class DriveApiDisabledError extends Error {} @@ -118,8 +141,12 @@ async function errorReason(response: Response): Promise { return googleErrorReasonFromText(text); } -async function driveError(response: Response): Promise { +async function driveError(response: Response, operation: string): Promise { let reason = await errorReason(response); + logger.warn("Google Drive API request failed", { + event: "google.drive.api.request.failed", provider: "Google Drive", operation, + httpStatus: response.status, ...(reason ? { providerReasons: [reason] } : {}), + }); if (response.status === 403 && reason === API_DISABLED_REASON) { return new DriveApiDisabledError( "the Google Drive API is not enabled for this OAuth project"); @@ -138,6 +165,34 @@ function optionalString(value: unknown, field: string): string | undefined { return value; } +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== "boolean") throw new Error(`Invalid Google Drive ${field}`); + return value; +} + +function optionalStringArray(value: unknown, field: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error(`Invalid Google Drive ${field}`); + let result: string[] = []; + for (let item of value) { + if (typeof item !== "string") throw new Error(`Invalid Google Drive ${field}`); + result.push(item); + } + return result; +} + +function parseDriveCapabilities(value: unknown): DriveFile["capabilities"] { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error("Invalid Google Drive file capabilities"); + let canAddChildren = optionalBoolean(value.canAddChildren, "file capability canAddChildren"); + let canTrash = optionalBoolean(value.canTrash, "file capability canTrash"); + return { + ...(canAddChildren === undefined ? {} : { canAddChildren }), + ...(canTrash === undefined ? {} : { canTrash }), + }; +} + function optionalFields(value: Record, fields: readonly string[]): Record { let result: Record = {}; for (let field of fields) { @@ -164,13 +219,9 @@ function parseDriveFile(value: unknown): DriveFile { if (!isRecord(value.shortcutDetails)) throw new Error("Invalid Google Drive shortcut details"); shortcutDetails = optionalFields(value.shortcutDetails, ["targetId", "targetMimeType"]); } - let parents: string[] | undefined; - if (value.parents !== undefined) { - if (!Array.isArray(value.parents) || value.parents.some(parent => typeof parent !== "string")) { - throw new Error("Invalid Google Drive file parents"); - } - parents = value.parents as string[]; - } + let parents = optionalStringArray(value.parents, "file parents"); + let trashed = optionalBoolean(value.trashed, "file trashed state"); + let capabilities = parseDriveCapabilities(value.capabilities); return { id: value.id, name: value.name, @@ -180,6 +231,8 @@ function parseDriveFile(value: unknown): DriveFile { ...(parents ? { parents } : {}), ...(owners ? { owners } : {}), ...(shortcutDetails ? { shortcutDetails } : {}), + ...(trashed === undefined ? {} : { trashed }), + ...(capabilities ? { capabilities } : {}), }; } @@ -200,6 +253,12 @@ function literalClause(field: string, operator: string, value: string): string { return `${field} ${operator} '${escapeDriveQueryLiteral(value)}'`; } +function validateCreationRequestId(requestId: string): void { + if (!UUID_V4_PATTERN.test(requestId)) { + throw new Error("Invalid Google Drive creation request ID"); + } +} + /** Assembles a Drive `q` from structured values. Trashed files are always excluded. */ export function buildDriveQuery(query: DriveFileQuery): string { let clauses = ["trashed = false"]; @@ -242,7 +301,7 @@ export class DriveApi { if (options.pageToken) params.set("pageToken", options.pageToken); if (options.corpora) params.set("corpora", options.corpora); if (options.driveId) params.set("driveId", options.driveId); - let body = await this.#getUnknown("/files", params); + let body = await this.#getUnknown("/files", params, "list files"); if (!isRecord(body)) throw new Error("Invalid Google Drive file-list response"); let files: DriveFile[] = []; if (body.files !== undefined) { @@ -256,13 +315,72 @@ export class DriveApi { /** Current metadata for one file. */ async getFile(fileId: string): Promise { let params = new URLSearchParams({ fields: DRIVE_FILE_ITEM_FIELDS, supportsAllDrives: "true" }); - return parseDriveFile(await this.#getUnknown(`/files/${encodeURIComponent(fileId)}`, params)); + return parseDriveFile(await this.#getUnknown( + `/files/${encodeURIComponent(fileId)}`, params, "get file")); + } + + /** Create one blank native Drive item in an already-authorized parent. */ + async createFile(options: DriveCreateFileOptions): Promise { + validateCreationRequestId(options.requestId); + let params = new URLSearchParams({ + fields: DRIVE_FILE_ITEM_FIELDS, supportsAllDrives: "true", + ignoreDefaultVisibility: "true", + }); + let body = { + name: options.name, + mimeType: options.mimeType, + parents: [options.parentId], + appProperties: { [CREATION_REQUEST_PROPERTY]: options.requestId }, + }; + return parseDriveFile(await this.#requestUnknown("/files", params, "create file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + } + + /** Find the sole file carrying a gatekeeper-generated creation marker. */ + async findFileByCreationRequestId(requestId: string): Promise { + validateCreationRequestId(requestId); + let params = new URLSearchParams({ + q: `appProperties has { key='${CREATION_REQUEST_PROPERTY}' and value='${requestId}' }`, + pageSize: "2", + fields: DRIVE_FILE_FIELDS, + spaces: "drive", + supportsAllDrives: "true", + includeItemsFromAllDrives: "true", + }); + let body = await this.#getUnknown("/files", params, "find created file"); + if (!isRecord(body)) throw new Error("Invalid Google Drive file-list response"); + let files: DriveFile[] = []; + if (body.files !== undefined) { + if (!Array.isArray(body.files)) throw new Error("Invalid Google Drive file-list response"); + files = body.files.map(parseDriveFile); + } + let nextPageToken = optionalString(body.nextPageToken, "nextPageToken"); + if (files.length > 1 || nextPageToken !== undefined) { + throw new Error("Multiple Google Drive files matched one creation request"); + } + return files[0]; + } + + /** Move one Drive item to trash. */ + async trashFile(fileId: string): Promise { + let params = new URLSearchParams({ fields: DRIVE_FILE_ITEM_FIELDS, supportsAllDrives: "true" }); + let file = parseDriveFile(await this.#requestUnknown( + `/files/${encodeURIComponent(fileId)}`, params, "trash file", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trashed: true }), + })); + if (file.trashed !== true) throw new Error("Google Drive did not trash the requested file"); } /** Current metadata for one shared drive. */ async getDrive(driveId: string): Promise { let params = new URLSearchParams({ fields: "id,name" }); - return parseDriveInfo(await this.#getUnknown(`/drives/${encodeURIComponent(driveId)}`, params)); + return parseDriveInfo(await this.#getUnknown( + `/drives/${encodeURIComponent(driveId)}`, params, "get shared drive")); } /** One page of shared drives visible to the connected account. */ @@ -274,7 +392,7 @@ export class DriveApi { if (options.nameContains?.trim()) { params.set("q", literalClause("name", "contains", options.nameContains.trim())); } - let body = await this.#getUnknown("/drives", params); + let body = await this.#getUnknown("/drives", params, "list shared drives"); if (!isRecord(body)) throw new Error("Invalid Google shared-drive list response"); let drives: DriveInfo[] = []; if (body.drives !== undefined) { @@ -314,8 +432,8 @@ export class DriveApi { "Content-Type": `multipart/mixed; boundary=${boundary}`, }, body, - }, this.getAccessToken); - if (!response.ok) throw await driveError(response); + }, this.getAccessToken, { timeoutMs: DRIVE_API_TIMEOUT_MS }); + if (!response.ok) throw await driveError(response, "check file access"); let contentType = response.headers.get("Content-Type") ?? ""; let responseBoundary = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)?.slice(1).find(Boolean); @@ -339,14 +457,33 @@ export class DriveApi { }); } - async #getUnknown(path: string, params: URLSearchParams): Promise { + async #getUnknown( + path: string, params: URLSearchParams, operation: string, + ): Promise { + return this.#requestUnknown(path, params, operation); + } + + async #requestUnknown( + path: string, + params: URLSearchParams, + operation: string, + init: RequestInit = {}, + ): Promise { + let headers = new Headers(init.headers); + headers.set("Accept", "application/json"); let response = await fetchWithAuthRetry( `${DRIVE_API_BASE}${path}?${params}`, - { headers: { Accept: "application/json" } }, - this.getAccessToken); - if (!response.ok) throw await driveError(response); + { ...init, headers }, + this.getAccessToken, + { timeoutMs: DRIVE_API_TIMEOUT_MS }, + ); + if (!response.ok) throw await driveError(response, operation); let text = await readBoundedText( response, MAX_JSON_RESPONSE_BYTES, "Google Drive response was too large"); - return JSON.parse(text); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid Google Drive JSON response"); + } } } diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 7de9f8acc..d93f56dff 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -19,6 +19,34 @@ export type DriveBindingScope = | { kind: "sharedDrive"; driveId: string } | { kind: "file"; fileId: string }; +/** Canonical destination metadata safe to persist after observation authorization. */ +export type DriveCreationParent = { id: string; name: string }; + +/** Whether current provider metadata remains inside immutable binding authority. */ +export function isDriveFileInScope(scope: DriveBindingScope, file: DriveFile): boolean { + switch (scope.kind) { + case "account": return true; + case "sharedDrive": return file.driveId === scope.driveId || file.id === scope.driveId; + case "file": return file.id === scope.fileId; + } +} + +/** Validate current provider metadata as a writable creation destination. */ +export function validateDriveCreationParent( + scope: DriveBindingScope, file: DriveFile, +): DriveCreationParent { + if (scope.kind === "file" || !isDriveFileInScope(scope, file)) { + throw new Error("The requested file is outside this Drive binding."); + } + if (file.mimeType !== FOLDER_MIME_TYPE) { + throw new Error("Drive creation parent must identify a folder"); + } + if (file.capabilities?.canAddChildren !== true) { + throw new Error("Drive creation parent does not allow adding children"); + } + return { id: file.id, name: file.name }; +} + type DriveSessionApi = Pick; type DriveSessionCoreOptions = { @@ -159,6 +187,31 @@ export class DriveSessionCore { } } + /** Resolve, validate, and authorize observation of a creation destination. */ + async resolveCreationParent(parentId?: string): Promise { + if (this.#scope.kind === "file") this.#outsideScope(); + if (parentId !== undefined && !parentId.trim()) { + throw new Error("parentId must not be empty"); + } + let requestedId = parentId ?? + (this.#scope.kind === "sharedDrive" ? this.#scope.driveId : "root"); + let parent = await this.#api.getFile(requestedId); + let resolved = validateDriveCreationParent(this.#scope, parent); + await this.#authorizeIds( + [parent.id], + "Check Google Drive creation destination", + "Check that the requested creation destination belongs to this Drive binding.", + ); + return resolved; + } + + /** Re-fetch and validate a previously resolved creation destination before mutation. */ + async revalidateCreationParent(parentId: string): Promise { + if (this.#scope.kind === "file") this.#outsideScope(); + if (!parentId.trim()) throw new Error("parentId must not be empty"); + return validateDriveCreationParent(this.#scope, await this.#api.getFile(parentId)); + } + async list(options: DriveListOptions = {}): Promise> { if (options.directParentId) await this.#assertParent(options.directParentId); if (this.#scope.kind === "file") return this.#exactFileCursor(); @@ -180,7 +233,7 @@ export class DriveSessionCore { async getEntry(fileId: string): Promise { if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); let file = await this.#api.getFile(fileId); - if (!this.#inScope(file)) this.#outsideScope(); + if (!isDriveFileInScope(this.#scope, file)) this.#outsideScope(); let entry = driveFileToEntry(file); await this.#authorizeIds([file.id], "Read Google Drive metadata", `Read metadata for Drive file ${file.id}.`); @@ -195,7 +248,7 @@ export class DriveSessionCore { ): Promise { if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); let file = await this.#api.getFile(fileId); - if (!this.#inScope(file)) this.#outsideScope(); + if (!isDriveFileInScope(this.#scope, file)) this.#outsideScope(); await this.#authorizeIds( [file.id], `Open ${description} from Google Drive`, @@ -214,7 +267,9 @@ export class DriveSessionCore { let page = await this.#api.listFiles({ ...query, ...this.#corpus(), pageToken }); return { items: page.files, ...(page.nextPageToken ? { nextPageToken: page.nextPageToken } : {}) }; }, - buildEntries: async files => files.filter(file => this.#inScope(file)).map(driveFileToEntry), + buildEntries: async files => files + .filter(file => isDriveFileInScope(this.#scope, file)) + .map(driveFileToEntry), authorize: async entries => { await this.#authorizeIds(entries.map(entry => entry.id), "Read Google Drive metadata", `Read metadata for ${entries.length} Drive ${entries.length === 1 ? "entry" : "entries"}.`); @@ -244,19 +299,11 @@ export class DriveSessionCore { : { corpora: "user" }; } - #inScope(file: DriveFile): boolean { - switch (this.#scope.kind) { - case "account": return true; - case "sharedDrive": - return file.driveId === this.#scope.driveId || file.id === this.#scope.driveId; - case "file": return file.id === this.#scope.fileId; - } - } async #assertParent(parentId: string): Promise { if (this.#scope.kind === "file") this.#outsideScope(); let parent = await this.#api.getFile(parentId); - if (!this.#inScope(parent)) this.#outsideScope(); + if (!isDriveFileInScope(this.#scope, parent)) this.#outsideScope(); if (parent.mimeType !== FOLDER_MIME_TYPE) throw new Error("directParentId must identify a folder"); await this.#authorizeIds([parent.id], "Check Google Drive folder", "Check that the requested parent folder belongs to this Drive binding."); From bb137251c78d9fda33cbf547221ea58ccae3286e Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 15:47:57 -0500 Subject: [PATCH 5/6] Add Google Drive creation lifecycle --- packages/gatekeeper-google/README.md | 12 +- .../__tests__/configurator-url.test.ts | 9 +- .../__tests__/drive-creation.test.ts | 318 ++++++++++++++++++ .../__tests__/resources.test.ts | 6 +- .../__tests__/workerd/native-sessions.test.ts | 142 +++++++- .../gatekeeper-google/src/approval-format.ts | 11 + .../drive-account-configurator-ui.tsx | 4 +- .../drive-file-configurator-ui.tsx | 2 +- .../shared-drive-configurator-ui.tsx | 2 +- .../gatekeeper-google/src/drive-creation.ts | 295 ++++++++++++++++ packages/gatekeeper-google/src/google.ts | 97 ++++-- packages/gatekeeper-google/src/resources.ts | 8 +- 12 files changed, 858 insertions(+), 48 deletions(-) create mode 100644 packages/gatekeeper-google/__tests__/drive-creation.test.ts create mode 100644 packages/gatekeeper-google/src/approval-format.ts create mode 100644 packages/gatekeeper-google/src/drive-creation.ts diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index 25fb317c9..598dec6e3 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -146,7 +146,7 @@ User — see Step 4.) You can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right). -## Google Drive read-only bindings +## Google Drive bindings Drive exposes three permanent resource URL forms: @@ -156,11 +156,15 @@ Drive exposes three permanent resource URL forms: Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs. -The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured metadata searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata and Markdown content; Sheets expose spreadsheet metadata and bounded A1 range reads. +The agent-facing Drive sessions report the binding scope, list entries, run structured metadata searches, and fetch one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata and Markdown content; Sheets expose spreadsheet metadata and bounded A1 range reads. -Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. The Drive API exposes no raw `q` strings, writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. +Account and shared-drive bindings also queue creation of blank native Google Docs, blank native Google Sheets, and folders. An omitted destination means the My Drive root for an account binding or the bound shared-drive root; callers may instead name an in-scope writable folder by immutable ID. Exact-file bindings remain read-only, even when the selected file is a folder. -Account-wide and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting either resource. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. +Each create call returns an asynchronous handle and submits a manual approval that names the destination. The new item contains no initial content and inherits the destination folder's permissions. `getCreationResult()` reports pending, rejected, failed, created, or reverted state. Revert moves an item to Drive trash; it never permanently deletes it. + +Every native open and creation destination lookup re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, and checks the exact MIME type and current capability before authorizing the observation. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. The Drive API exposes no agent-authored raw `q` strings, generic upload or write primitive, shortcut traversal, arbitrary download or export, or Workers AI extraction. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. + +Account bindings request `drive.metadata.readonly`, `documents.readonly`, `spreadsheets.readonly`, and `drive.file`. Shared-drive bindings request `drive.readonly` plus `drive.file`. The new scope limits write access to files this app creates or the user explicitly opens with it; the gatekeeper further restricts creation to an authorized destination in the bound scope. Older account and shared-drive connections are prompted to expand consent. Exact-file bindings remain on the three read-only scopes, and direct Google Doc and Spreadsheet grants are unchanged. Account and shared-drive bindings remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires an explicit Drive grant and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests; there is deliberately no cached access verdict, so revoked access fails closed on the next open. diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index 5c1427b56..8951d1993 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -119,16 +119,17 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" }); }); - it("explains native Doc and Sheet reads at every Drive scope", () => { + it("advertises creation only for account and shared-drive bindings", () => { expect(renderedCopy(driveAccountConfigurator)).toContain( - "Returns metadata for every item and read-only content sessions for native Docs and Sheets.", + "approved blank-item creation", ); expect(renderedCopy(sharedDriveConfigurator)).toContain( - "Search its files and read native Google Docs and Sheets.", + "create blank Docs, Sheets, and folders", ); expect(renderedCopy(driveFileConfigurator)).toContain( - "A selected native Google Doc or Sheet also provides read-only content.", + "one read-only file binding", ); + expect(renderedCopy(driveFileConfigurator)).not.toContain("create blank"); }); it("round-trips an encoded shared-drive ID", () => { diff --git a/packages/gatekeeper-google/__tests__/drive-creation.test.ts b/packages/gatekeeper-google/__tests__/drive-creation.test.ts new file mode 100644 index 000000000..1ac5455c8 --- /dev/null +++ b/packages/gatekeeper-google/__tests__/drive-creation.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ApprovalQueue } from "@gadgets/workshop-shared/gatekeeper"; +import { + applyDriveCreation, DriveCreationStore, readDriveCreationState, rejectDriveCreation, + revertDriveCreation, submitDriveCreation, + type DriveCreationApi, type DriveCreationStorage, +} from "../src/drive-creation"; +import type { DriveFile } from "../src/drive-api"; + +const REQUEST_ID = "123e4567-e89b-42d3-a456-426614174000"; +const DOC_MIME_TYPE = "application/vnd.google-apps.document"; +const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + +class FakeKv implements DriveCreationStorage { + entries = new Map(); + events: string[] = []; + failDeleteKey: string | undefined; + + get(key: string): T | undefined { + return this.entries.get(key) as T | undefined; + } + + put(key: string, value: T): void { + this.events.push(`put:${key}`); + this.entries.set(key, value); + } + + delete(key: string): void { + this.events.push(`delete:${key}`); + if (this.failDeleteKey === key) { + this.failDeleteKey = undefined; + throw new Error("delete crash"); + } + this.entries.delete(key); + } + + list({ prefix }: { prefix: string }): Iterable<[string, T]> { + return [...this.entries] + .filter(([key]) => key.startsWith(prefix)) as [string, T][]; + } +} + +const file = (overrides: Partial = {}): DriveFile => ({ + id: "created-1", + name: "Quarterly plan", + mimeType: DOC_MIME_TYPE, + parents: ["parent-1"], + trashed: false, + capabilities: { canTrash: true }, + ...overrides, +}); + +const parent = (overrides: Partial = {}): DriveFile => file({ + id: "parent-1", + name: "Plans", + mimeType: FOLDER_MIME_TYPE, + parents: ["root"], + capabilities: { canAddChildren: true }, + ...overrides, +}); + +function fakeApi(overrides: Partial = {}): DriveCreationApi { + return { + getFile: vi.fn(async id => id === "parent-1" ? parent() : file({ id })), + findFileByCreationRequestId: vi.fn(async () => undefined), + createFile: vi.fn(async () => file()), + trashFile: vi.fn(async () => {}), + ...overrides, + }; +} + +const action = { + kind: "googleDoc" as const, + name: "Quarterly plan", + parentId: "parent-1", + requestId: REQUEST_ID, +}; + +async function submit( + storage: FakeKv, + approvalQueue: Pick = { + submitAction: vi.fn(async () => {}), + }, + overrides: Partial<{ kind: "googleDoc" | "googleSheet" | "folder"; name: string }> = {}, +) { + return submitDriveCreation({ + storage, + approvalQueue, + kind: overrides.kind ?? "googleDoc", + name: overrides.name ?? "Quarterly plan", + parent: { id: "parent-1", name: "Plans" }, + requestId: REQUEST_ID, + }); +} + +describe("Drive creation submission", () => { + it("returns a handle and fences untrusted names in the manual approval", async () => { + let storage = new FakeKv(); + let approvalQueue = { + submitAction: vi.fn(async (..._args: Parameters) => {}), + }; + let name = "Quarterly ``` plan\nInjected heading"; + + await expect(submit(storage, approvalQueue, { name })).resolves.toEqual({ + id: 1, kind: "googleDoc", name, + }); + expect(new DriveCreationStore(storage).getAction(1)).toEqual({ + kind: "googleDoc", name, parentId: "parent-1", requestId: REQUEST_ID, + }); + expect(approvalQueue.submitAction).toHaveBeenCalledTimes(1); + let [id, description] = approvalQueue.submitAction.mock.calls[0]!; + expect(id).toBe(1); + expect(description.title).toBe("Create Google Doc: Quarterly ``` plan Injected heading"); + expect(description.description).toContain( + "````\nQuarterly ``` plan\nInjected heading\n````", + ); + expect(description.description).toContain("```\nPlans\n```"); + expect(description.description).toContain("```\nparent-1\n```"); + expect(description.description).toContain("blank"); + expect(description.description).toContain("inherits the destination folder's permissions"); + expect(description).toEqual(expect.objectContaining({ + implementsRevert: true, awaitDecision: true, + })); + expect(description).not.toHaveProperty("actionKind"); + }); + + it("rejects the 101st pending creation without submitting it", async () => { + let storage = new FakeKv(); + let store = new DriveCreationStore(storage); + for (let i = 0; i < 100; i++) store.submit(action); + let approvalQueue = { submitAction: vi.fn(async () => {}) }; + + await expect(submit(storage, approvalQueue)).rejects.toThrow( + "Too many pending Google Drive creations", + ); + expect(approvalQueue.submitAction).not.toHaveBeenCalled(); + expect(store.pendingCount()).toBe(100); + }); + + it("removes pending state when approval submission fails", async () => { + let storage = new FakeKv(); + let approvalQueue = { + submitAction: vi.fn(async () => { throw new Error("queue unavailable"); }), + }; + + await expect(submit(storage, approvalQueue)).rejects.toThrow("queue unavailable"); + expect(new DriveCreationStore(storage).getAction(1)).toBeUndefined(); + expect(() => readDriveCreationState(storage, 1)).toThrow( + "Unknown Google Drive creation action: 1", + ); + }); +}); + +describe("Drive creation action lifecycle", () => { + it("reports pending, then records rejection before removing pending state", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "pending" }); + + rejectDriveCreation(storage, handle.id); + + expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "rejected" }); + expect(new DriveCreationStore(storage).getAction(handle.id)).toBeUndefined(); + expect(storage.events.indexOf("put:drive:create:outcome:1")) + .toBeLessThan(storage.events.indexOf("delete:pending:action:1")); + }); + + it("stores provider failure while keeping the action retryable", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi({ + createFile: vi.fn(async () => { throw new Error("provider unavailable"); }), + }); + + await expect(applyDriveCreation( + { storage, api, scope: { kind: "account" } }, handle.id, + )).rejects.toThrow("provider unavailable"); + expect(readDriveCreationState(storage, handle.id)).toEqual({ + status: "failed", message: "provider unavailable", + }); + expect(new DriveCreationStore(storage).getAction(handle.id)).toEqual(action); + }); + + it("records creation before removing pending state and trashes it on revert", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi(); + + await applyDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + + expect(readDriveCreationState(storage, handle.id)).toEqual({ + status: "created", kind: "googleDoc", fileId: "created-1", + }); + expect(new DriveCreationStore(storage).getAction(handle.id)).toBeUndefined(); + expect(storage.events.indexOf("put:drive:create:outcome:1")) + .toBeLessThan(storage.events.indexOf("delete:pending:action:1")); + + await revertDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + expect(api.trashFile).toHaveBeenCalledWith("created-1"); + expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "reverted" }); + }); + + it("finishes cleanup without another provider call after success storage survives a crash", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi(); + storage.failDeleteKey = "pending:action:1"; + + await expect(applyDriveCreation( + { storage, api, scope: { kind: "account" } }, handle.id, + )).rejects.toThrow("delete crash"); + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + expect(new DriveCreationStore(storage).getAction(handle.id)).toBeDefined(); + + await applyDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + expect(api.createFile).toHaveBeenCalledTimes(1); + expect(api.findFileByCreationRequestId).toHaveBeenCalledTimes(1); + expect(new DriveCreationStore(storage).getAction(handle.id)).toBeUndefined(); + }); + + it("recovers a lost create response through the private app-property marker", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let created: DriveFile | undefined; + let createFile = vi.fn(async () => { + created = file(); + throw new Error("connection lost after create"); + }); + let api = fakeApi({ + createFile, + findFileByCreationRequestId: vi.fn(async () => created), + }); + + await expect(applyDriveCreation( + { storage, api, scope: { kind: "account" } }, handle.id, + )).rejects.toThrow("connection lost after create"); + await applyDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + + expect(createFile).toHaveBeenCalledTimes(1); + expect(readDriveCreationState(storage, handle.id)).toEqual({ + status: "created", kind: "googleDoc", fileId: "created-1", + }); + }); + + it.each([ + ["name", { name: "Unexpected" }, { kind: "account" } as const], + ["MIME type", { mimeType: "application/pdf" }, { kind: "account" } as const], + ["parent", { parents: ["other-parent"] }, { kind: "account" } as const], + ["trash state", { trashed: true }, { kind: "account" } as const], + ["shared-drive scope", { driveId: "drive-2" }, + { kind: "sharedDrive", driveId: "drive-1" } as const], + ])("fails closed when a marker match has mismatched %s", async (_field, mismatch, scope) => { + let storage = new FakeKv(); + let handle = await submit(storage); + let createFile = vi.fn(async () => file()); + let api = fakeApi({ + getFile: vi.fn(async () => parent( + scope.kind === "sharedDrive" ? { driveId: scope.driveId } : {}, + )), + findFileByCreationRequestId: vi.fn(async () => file(mismatch)), + createFile, + }); + + await expect(applyDriveCreation({ storage, api, scope }, handle.id)) + .rejects.toThrow("creation marker matched unexpected file metadata"); + expect(createFile).not.toHaveBeenCalled(); + expect(readDriveCreationState(storage, handle.id).status).toBe("failed"); + expect(new DriveCreationStore(storage).getAction(handle.id)).toBeDefined(); + }); + + it("fails before marker lookup when the approved parent moved out of scope", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi({ + getFile: vi.fn(async () => parent({ driveId: "drive-2" })), + }); + + await expect(applyDriveCreation({ + storage, api, scope: { kind: "sharedDrive", driveId: "drive-1" }, + }, handle.id)).rejects.toThrow(/outside this Drive binding/); + expect(api.findFileByCreationRequestId).not.toHaveBeenCalled(); + expect(api.createFile).not.toHaveBeenCalled(); + }); + + it("refuses revert when the created item cannot currently be trashed", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi(); + await applyDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + api.getFile = vi.fn(async () => file({ capabilities: { canTrash: false } })); + + await expect(revertDriveCreation( + { storage, api, scope: { kind: "account" } }, handle.id, + )).rejects.toThrow("cannot currently be moved to trash"); + expect(api.trashFile).not.toHaveBeenCalled(); + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + }); + + it("retains 100 terminal outcomes without aging out a failed pending action", () => { + let storage = new FakeKv(); + let store = new DriveCreationStore(storage); + let pendingId = store.submit(action); + store.putFailure(pendingId, "retry me"); + for (let i = 0; i < 101; i++) { + let id = store.submit(action); + store.finish(id, { status: "rejected" }); + } + + expect(store.getAction(pendingId)).toEqual(action); + expect(store.getOutcome(pendingId)).toEqual({ status: "failed", message: "retry me" }); + expect([...storage.list({ prefix: "drive:create:outcome:" })]).toHaveLength(101); + expect([...storage.list({ prefix: "pending:action:" })]).toHaveLength(1); + expect(() => readDriveCreationState(storage, 2)).toThrow( + "Unknown Google Drive creation action: 2", + ); + expect(readDriveCreationState(storage, 102)).toEqual({ status: "rejected" }); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index c949ca305..58b7ca009 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -55,14 +55,14 @@ describe("resource declarations", () => { expect(new Set(Object.values(RESOURCE_BY_KIND)).size).toBe(SUPPORTED_RESOURCES.length); }); - it("advertises native Docs and Sheets only on Drive resources", () => { + it("advertises creation only on broad Drive resources", () => { expect([ GOOGLE_DRIVE_RESOURCE.description, GOOGLE_SHARED_DRIVE_RESOURCE.description, GOOGLE_DRIVE_FILE_RESOURCE.description, ]).toEqual([ - "Find files and folders, and read native Google Docs and Sheets, in My Drive or Shared with me.", - "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", + "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in My Drive or Shared with me.", + "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in one organization-owned shared drive.", "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", ]); }); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index d3e1ba464..efce921ff 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -2,6 +2,13 @@ import { RpcStub, RpcTarget } from "cloudflare:workers"; import type { ActionDescription, ApprovalQueue, HookController, HookDescription, ObservationDescription, } from "@gadgets/workshop-shared/gatekeeper"; +import { + applyDriveCreation, DriveCreationStore, type DriveCreationStorage, +} from "../../src/drive-creation"; +import type { DriveBindingScope } from "../../src/drive-session"; +import type { + DriveCreationHandle, GoogleDriveReadSession, GoogleDriveSession, +} from "../../src/drive-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GoogleDocsApi } from "../../src/docs-api"; import { DriveApi } from "../../src/drive-api"; @@ -10,7 +17,9 @@ import { GoogleSheetsApi } from "../../src/sheets-api"; const DOC_MIME = "application/vnd.google-apps.document"; const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; let providerUrls: string[]; +let createdFiles: Map>; async function getAccessToken(): Promise { return "access-token"; @@ -18,13 +27,14 @@ async function getAccessToken(): Promise { class TestApprovalQueue extends RpcTarget implements ApprovalQueue { readonly observations: ObservationDescription[] = []; + readonly actions: { id: number; description: ActionDescription }[] = []; async authorizeObservation(description: ObservationDescription): Promise { this.observations.push(description); } - async submitAction(_action: number, _description: ActionDescription): Promise { - throw new Error("Unexpected action submission"); + async submitAction(action: number, description: ActionDescription): Promise { + this.actions.push({ id: action, description }); } async bindHook( @@ -35,22 +45,59 @@ class TestApprovalQueue extends RpcTarget implements ApprovalQueue { } } -function providerFile(id: string, mimeType: string) { +function providerFile( + id: string, mimeType: string, overrides: Record = {}, +) { return { id, name: id === "doc-1" ? "Quarterly plan" : "Forecast", mimeType, modifiedTime: "2026-08-20T12:00:00Z", + trashed: false, + ...overrides, }; } function installProvider() { const urls: string[] = []; - vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { - const url = new URL(input instanceof Request ? input.url : input.toString()); + createdFiles = new Map(); + vi.stubGlobal("fetch", vi.fn(async ( + input: string | URL | Request, init?: RequestInit, + ) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); urls.push(url.toString()); + if (url.hostname === "www.googleapis.com" && url.pathname.endsWith("/drive/v3/files")) { + if (request.method === "GET") return Response.json({ files: [] }); + if (request.method === "POST") { + const body = await request.json() as { + name: string; mimeType: string; parents: string[]; + }; + const created = providerFile(`created-${createdFiles.size + 1}`, body.mimeType, { + name: body.name, + parents: body.parents, + capabilities: { canTrash: true }, + }); + createdFiles.set(created.id, created); + return Response.json(created); + } + } if (url.hostname === "www.googleapis.com" && url.pathname.includes("/drive/v3/files/")) { const id = decodeURIComponent(url.pathname.split("/").at(-1)!); + if (request.method === "PATCH") { + const current = createdFiles.get(id); + if (!current) throw new Error(`Unknown created file: ${id}`); + const trashed = { ...current, trashed: true }; + createdFiles.set(id, trashed); + return Response.json(trashed); + } + const created = createdFiles.get(id); + if (created) return Response.json(created); + if (id === "root") { + return Response.json(providerFile(id, FOLDER_MIME, { + name: "My Drive", capabilities: { canAddChildren: true }, + })); + } const mimeType = id === "doc-1" ? DOC_MIME : SHEET_MIME; return Response.json(providerFile(id, mimeType)); } @@ -68,17 +115,41 @@ function installProvider() { return urls; } -function newSession() { +class TestStorage implements DriveCreationStorage { + entries = new Map(); + + get(key: string): T | undefined { + return this.entries.get(key) as T | undefined; + } + + put(key: string, value: T): void { + this.entries.set(key, value); + } + + delete(key: string): void { + this.entries.delete(key); + } + + list({ prefix }: { prefix: string }): Iterable<[string, T]> { + return [...this.entries] + .filter(([key]) => key.startsWith(prefix)) as [string, T][]; + } +} + +function newSession(scope: DriveBindingScope = { kind: "account" }) { const queue = new TestApprovalQueue(); const queueStub: RpcStub = new RpcStub(queue); + const storage = new TestStorage(); + const driveApi = new DriveApi(getAccessToken); const session = new GoogleDriveSessionImpl( - new DriveApi(getAccessToken), + driveApi, new GoogleDocsApi(getAccessToken), new GoogleSheetsApi(getAccessToken), - { kind: "account" }, + scope, + storage, queueStub, ); - return { queue, session }; + return { driveApi, queue, session, storage }; } beforeEach(() => { @@ -124,3 +195,56 @@ describe("Drive nested native sessions", () => { await expect(doc.getContent()).rejects.toThrow(); }); }); + +describe("Drive creation RPC", () => { + it("round-trips handles and authoritative created outcomes through a real RPC stub", async () => { + const { driveApi, queue, session, storage } = newSession(); + const rpc: RpcStub = new RpcStub(session); + + const doc = await rpc.createGoogleDoc({ name: "Quarterly plan" }); + const sheet = await rpc.createGoogleSheet({ name: "Forecast" }); + const folder = await rpc.createFolder({ name: "Planning" }); + + expect([doc, sheet, folder]).toEqual([ + { id: 1, kind: "googleDoc", name: "Quarterly plan" }, + { id: 2, kind: "googleSheet", name: "Forecast" }, + { id: 3, kind: "folder", name: "Planning" }, + ]); + expect(queue.actions.map(({ description }) => description)).toEqual( + Array(3).fill(expect.objectContaining({ + implementsRevert: true, awaitDecision: true, + })), + ); + expect(queue.actions.every(({ description }) => !("actionKind" in description))).toBe(true); + + await applyDriveCreation( + { storage, api: driveApi, scope: { kind: "account" } }, doc.id, + ); + providerUrls.splice(0); + const tampered = { ...doc, kind: "folder", name: "Forged" } as DriveCreationHandle; + + await expect(rpc.getCreationResult(tampered)).resolves.toEqual({ + status: "created", + kind: "googleDoc", + entry: expect.objectContaining({ + id: "created-1", name: "Quarterly plan", mimeType: DOC_MIME, + }), + }); + expect(providerUrls.some(url => url.includes("/drive/v3/files/created-1"))).toBe(true); + rpc[Symbol.dispose](); + }); + + it("denies a cast file-scoped creation before provider access or action submission", async () => { + const { queue, session, storage } = newSession({ kind: "file", fileId: "doc-1" }); + const readSession: GoogleDriveReadSession = session; + const bypass = readSession as unknown as GoogleDriveSession; + providerUrls.splice(0); + + await expect(bypass.createFolder({ name: "Not allowed" })) + .rejects.toThrow(/outside this Drive binding/); + expect(providerUrls).toEqual([]); + expect(queue.actions).toEqual([]); + expect(new DriveCreationStore(storage).pendingCount()).toBe(0); + session[Symbol.dispose](); + }); +}); diff --git a/packages/gatekeeper-google/src/approval-format.ts b/packages/gatekeeper-google/src/approval-format.ts new file mode 100644 index 000000000..ac9012b3d --- /dev/null +++ b/packages/gatekeeper-google/src/approval-format.ts @@ -0,0 +1,11 @@ +/** Bound an approval title to one display-safe line. */ +export function sanitizeApprovalTitle(value: string): string { + return value.replace(/[\r\n]+/g, " ").slice(0, 200); +} + +/** Fence an untrusted value so it cannot forge surrounding approval Markdown. */ +export function formatApprovalField(label: string, value: string): string { + let fence = "```"; + while (value.includes(fence)) fence += "`"; + return `**${label}:**\n\n${fence}\n${value}\n${fence}`; +} diff --git a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx index 4d58313ca..6a9f4db57 100644 --- a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx @@ -8,12 +8,12 @@ export default { initialValuesFromResourceUrl: () => ({ scope: "account" }), render({ setValues }) { return
- + setValues({ scope: "account" })} /> diff --git a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx index 386bf19fb..92abf225b 100644 --- a/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-file-configurator-ui.tsx @@ -11,7 +11,7 @@ export default { }), render({ values, setValues, ui }) { return
- + - + = { + googleDoc: "application/vnd.google-apps.document", + googleSheet: "application/vnd.google-apps.spreadsheet", + folder: "application/vnd.google-apps.folder", +}; + +const KIND_LABEL: Record = { + googleDoc: "Google Doc", + googleSheet: "Google Sheet", + folder: "folder", +}; + +const logger = obsContext.createLogger({ + component: "gatekeeper.google.drive-creation", vendorId: "google", +}); + +/** Synchronous Durable Object KV operations used by Drive creation state. */ +export interface DriveCreationStorage { + get(key: string): T | undefined; + put(key: string, value: T): void; + delete(key: string): void; + list(options: { prefix: string }): Iterable<[string, T]>; +} + +/** Narrow provider surface used by creation callbacks. */ +export type DriveCreationApi = Pick< + DriveApi, "getFile" | "findFileByCreationRequestId" | "createFile" | "trashFile" +>; + +/** Authoritative request persisted until the approval callback reaches a terminal state. */ +export type DriveCreationAction = { + kind: DriveCreationKind; + name: string; + parentId: string; + requestId: string; +}; + +/** Persisted callback outcome; provider metadata is intentionally represented only by file ID. */ +export type StoredDriveCreationOutcome = + | { status: "rejected" } + | { status: "failed"; message: string } + | { status: "created"; kind: DriveCreationKind; fileId: string } + | { status: "reverted" }; + +/** Current authoritative state before created metadata is freshly observed. */ +export type StoredDriveCreationState = + | { status: "pending" } + | StoredDriveCreationOutcome; + +type StoredOutcomeRecord = { + sequence: number; + outcome: StoredDriveCreationOutcome; +}; + +/** Durable action and bounded outcome storage for one Drive binding. */ +export class DriveCreationStore { + constructor(private storage: DriveCreationStorage) {} + + submit(action: DriveCreationAction): number { + let id = this.storage.get(NEXT_ACTION_ID_KEY) ?? 1; + this.storage.put(NEXT_ACTION_ID_KEY, id + 1); + this.storage.put(this.#actionKey(id), action); + return id; + } + + pendingCount(): number { + return [...this.storage.list({ prefix: ACTION_PREFIX })].length; + } + + getAction(id: number): DriveCreationAction | undefined { + return this.storage.get(this.#actionKey(id)); + } + + removeAction(id: number): void { + this.storage.delete(this.#actionKey(id)); + } + + getOutcome(id: number): StoredDriveCreationOutcome | undefined { + return this.storage.get(this.#outcomeKey(id))?.outcome; + } + + putFailure(id: number, message: string): void { + this.#putOutcome(id, { status: "failed", message }); + } + + finish(id: number, outcome: Exclude): void { + this.#putOutcome(id, outcome); + this.removeAction(id); + this.#pruneTerminalOutcomes(); + } + + cleanupTerminal(id: number): void { + this.removeAction(id); + this.#pruneTerminalOutcomes(); + } + + #actionKey(id: number): string { + return `${ACTION_PREFIX}${id}`; + } + + #outcomeKey(id: number): string { + return `${OUTCOME_PREFIX}${id}`; + } + + #putOutcome(id: number, outcome: StoredDriveCreationOutcome): void { + let sequence = this.storage.get(NEXT_OUTCOME_SEQUENCE_KEY) ?? 1; + this.storage.put(NEXT_OUTCOME_SEQUENCE_KEY, sequence + 1); + this.storage.put(this.#outcomeKey(id), { sequence, outcome } satisfies StoredOutcomeRecord); + } + + #pruneTerminalOutcomes(): void { + let terminal = [...this.storage.list({ prefix: OUTCOME_PREFIX })] + .map(([key, record]) => ({ + id: Number(key.slice(OUTCOME_PREFIX.length)), key, sequence: record.sequence, + })) + .filter(({ id }) => Number.isFinite(id) && this.getAction(id) === undefined) + .toSorted((a, b) => a.sequence - b.sequence); + for (let record of terminal.slice(0, -MAX_TERMINAL_OUTCOMES)) { + this.storage.delete(record.key); + } + } +} + +/** Reject empty names before any provider lookup. */ +export function validateDriveCreationName(name: string): void { + if (!name.trim()) throw new Error("Google Drive creation name must not be empty"); +} + +/** Reject submissions before provider lookup once the binding has 100 unresolved creates. */ +export function assertDriveCreationCapacity(storage: DriveCreationStorage): void { + if (new DriveCreationStore(storage).pendingCount() >= MAX_PENDING_CREATIONS) { + throw new Error( + "Too many pending Google Drive creations. Resolve existing actions before adding more.", + ); + } +} + +/** Persist one request and submit its manual approval description. */ +export async function submitDriveCreation(options: { + storage: DriveCreationStorage; + approvalQueue: Pick; + kind: DriveCreationKind; + name: string; + parent: DriveCreationParent; + requestId?: string; +}): Promise { + validateDriveCreationName(options.name); + assertDriveCreationCapacity(options.storage); + let action: DriveCreationAction = { + kind: options.kind, + name: options.name, + parentId: options.parent.id, + requestId: options.requestId ?? crypto.randomUUID(), + }; + let store = new DriveCreationStore(options.storage); + let id = store.submit(action); + try { + await options.approvalQueue.submitAction(id, { + title: sanitizeApprovalTitle(`Create ${KIND_LABEL[action.kind]}: ${action.name}`), + description: [ + `Create a blank ${KIND_LABEL[action.kind]} in Google Drive. ` + + "The new item inherits the destination folder's permissions.", + formatApprovalField("Name", action.name), + formatApprovalField("Destination folder", options.parent.name), + formatApprovalField("Destination folder ID", options.parent.id), + ].join("\n\n"), + implementsRevert: true, + awaitDecision: true, + }); + } catch (error) { + store.removeAction(id); + throw error; + } + return { id, kind: action.kind, name: action.name }; +} + +type DriveCreationRuntime = { + storage: DriveCreationStorage; + api: DriveCreationApi; + scope: DriveBindingScope; +}; + +/** Read persisted state by authoritative numeric action ID. */ +export function readDriveCreationState( + storage: DriveCreationStorage, actionId: number, +): StoredDriveCreationState { + let store = new DriveCreationStore(storage); + let outcome = store.getOutcome(actionId); + if (outcome) return outcome; + if (store.getAction(actionId)) return { status: "pending" }; + throw new Error(`Unknown Google Drive creation action: ${actionId}`); +} + +/** Apply or idempotently recover one approved creation. */ +export async function applyDriveCreation( + runtime: DriveCreationRuntime, actionId: number, +): Promise { + let store = new DriveCreationStore(runtime.storage); + let outcome = store.getOutcome(actionId); + if (outcome && outcome.status !== "failed") { + store.cleanupTerminal(actionId); + return; + } + let action = store.getAction(actionId); + if (!action) throw new Error(`Unknown pending Google Drive creation action: ${actionId}`); + + let created: DriveFile; + try { + let parent = await runtime.api.getFile(action.parentId); + validateDriveCreationParent(runtime.scope, parent); + created = await runtime.api.findFileByCreationRequestId(action.requestId) ?? + await runtime.api.createFile({ + name: action.name, + mimeType: MIME_TYPE_BY_KIND[action.kind], + parentId: action.parentId, + requestId: action.requestId, + }); + validateCreatedFile(runtime.scope, action, created); + } catch (error) { + store.putFailure(actionId, failureMessage(error)); + logger.warn("Drive creation action failed", { + event: "drive.creation.apply.failed", actionId, operation: "apply", error, + }); + throw error; + } + + store.finish(actionId, { status: "created", kind: action.kind, fileId: created.id }); +} + +/** Record rejection before removing retryable pending state. */ +export function rejectDriveCreation(storage: DriveCreationStorage, actionId: number): void { + let store = new DriveCreationStore(storage); + let outcome = store.getOutcome(actionId); + if (outcome && outcome.status !== "failed") { + store.cleanupTerminal(actionId); + return; + } + if (store.getAction(actionId)) store.finish(actionId, { status: "rejected" }); +} + +/** Trash a currently authorized created item and record its reverted state. */ +export async function revertDriveCreation( + runtime: DriveCreationRuntime, actionId: number, +): Promise { + let store = new DriveCreationStore(runtime.storage); + let outcome = store.getOutcome(actionId); + if (outcome?.status === "reverted") return; + if (outcome?.status !== "created") { + throw new Error(`Google Drive creation action ${actionId} cannot be reverted`); + } + let file = await runtime.api.getFile(outcome.fileId); + if (runtime.scope.kind === "file" || !isDriveFileInScope(runtime.scope, file)) { + throw new Error("The requested file is outside this Drive binding."); + } + if (file.capabilities?.canTrash !== true) { + throw new Error("The created Google Drive item cannot currently be moved to trash"); + } + await runtime.api.trashFile(file.id); + store.finish(actionId, { status: "reverted" }); +} + +function validateCreatedFile( + scope: DriveBindingScope, action: DriveCreationAction, file: DriveFile, +): void { + if (scope.kind === "file" || + isDriveFileInScope(scope, file) === false || + file.name !== action.name || + file.mimeType !== MIME_TYPE_BY_KIND[action.kind] || + file.trashed !== false || + file.parents?.length !== 1 || + file.parents[0] !== action.parentId) { + throw new Error("Google Drive creation marker matched unexpected file metadata"); + } +} + +function failureMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 1000); +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 6866722df..ef764d9a3 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -17,7 +17,10 @@ import { DriveApi } from "./drive-api"; import { DriveSessionCore, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, type DriveBindingScope, } from "./drive-session"; -import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; +import type { + DriveCreationHandle, DriveCreationKind, DriveCreationOptions, DriveCreationOutcome, DriveEntry, + DriveListOptions, DriveSearchQuery, GoogleDriveSession, +} from "./drive-types"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; import { BigQueryDataset, BigQueryDryRunResult, BigQueryField, BigQueryProject, @@ -74,6 +77,12 @@ import { } from "./resources"; import { ObserverCheck, ObserverTracker } from "./observers"; import { CursorPager, Pager } from "./cursor"; +import { formatApprovalField, sanitizeApprovalTitle } from "./approval-format"; +import { + applyDriveCreation, assertDriveCreationCapacity, readDriveCreationState, rejectDriveCreation, + revertDriveCreation, submitDriveCreation, validateDriveCreationName, + type DriveCreationStorage, +} from "./drive-creation"; const GOOGLE_DOC_TYPES_CODE = [DOCS_READ_TYPES_CODE, DOCS_TYPES_CODE].join("\n"); const GOOGLE_DRIVE_TYPES_CODE = [ @@ -1180,17 +1189,6 @@ class GmailSessionImpl extends RpcTarget implements GmailSession { } } -function sanitizeApprovalTitle(value: string): string { - return value.replace(/[\r\n]+/g, " ").slice(0, 200); -} - -function formatApprovalField(label: string, value: string): string { - // Use a fence longer than any backtick run in the value, so untrusted email - // fields render verbatim and cannot forge surrounding approval Markdown. - let fence = "```"; - while (value.includes(fence)) fence += "`"; - return `**${label}:**\n\n${fence}\n${value}\n${fence}`; -} function describeOutboundMessage(intro: string, message: GmailOutboundMessage): string { let fields = [ @@ -2991,7 +2989,7 @@ export class GoogleDriveGatekeeperImpl return { url: GOOGLE_DRIVE_RESOURCE.urlPattern, title: "Google Drive Account", - snippet: "Find files and folders and read native Google Docs and Sheets in My Drive or Shared with me", + snippet: "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in My Drive or Shared with me", suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; @@ -3001,7 +2999,7 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.driveId)}`, title: drive.name, - snippet: `Find files and folders and read native Google Docs and Sheets in organization-owned shared drive "${drive.name}"`, + snippet: `Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in organization-owned shared drive "${drive.name}"`, suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; @@ -3010,7 +3008,7 @@ export class GoogleDriveGatekeeperImpl return { url: `https://drive.google.com/file/d/${encodeURIComponent(scope.fileId)}/view`, title: file.name, - snippet: `Read metadata and, when native, Google Doc or Sheet content from Drive file "${file.name}"`, + snippet: `Read-only metadata and, when native, Google Doc or Sheet content from Drive file "${file.name}"`, suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveReadSession", }; @@ -3034,16 +3032,30 @@ export class GoogleDriveGatekeeperImpl new GoogleDocsApi(getDriveAccessToken), new GoogleSheetsApi(getDriveAccessToken), this.ctx.props.scope, + this.ctx.storage.kv, approvalQueue.dup(), prepareObservation, ); } - /** Read-only — no side-effecting actions. */ - async applyAction(_action: number): Promise {} - async rejectAction(_action: number): Promise {} - revertAction(_action: number): Promise { - throw new Error("Google Drive gatekeeper has no writable actions to revert"); + async applyAction(action: number): Promise { + await applyDriveCreation(this.#creationRuntime(), action); + } + + async rejectAction(action: number): Promise { + rejectDriveCreation(this.ctx.storage.kv, action); + } + + async revertAction(action: number): Promise { + await revertDriveCreation(this.#creationRuntime(), action); + } + + #creationRuntime() { + return { + storage: this.ctx.storage.kv, + api: new DriveApi(opts => this.#getAccessToken(opts)), + scope: this.ctx.props.scope, + }; } #observerTracker(): ObserverTracker> { @@ -3140,12 +3152,15 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess #docsApi: GoogleDocsApi; #sheetsApi: GoogleSheetsApi; #approvalQueue: RpcStub; + #scope: DriveBindingScope; + #storage: DriveCreationStorage; constructor( driveApi: DriveApi, docsApi: GoogleDocsApi, sheetsApi: GoogleSheetsApi, scope: DriveBindingScope, + storage: DriveCreationStorage, approvalQueue: RpcStub, prepareObservation?: (fileIds: string[]) => Promise>, ) { @@ -3153,6 +3168,8 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess this.#driveApi = driveApi; this.#docsApi = docsApi; this.#sheetsApi = sheetsApi; + this.#scope = scope; + this.#storage = storage; this.#approvalQueue = approvalQueue; this.#core = new DriveSessionCore({ api: driveApi, @@ -3182,6 +3199,46 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess return this.#core.getEntry(fileId); } + createGoogleDoc(options: DriveCreationOptions): Promise { + return this.#submitCreation("googleDoc", options); + } + + createGoogleSheet(options: DriveCreationOptions): Promise { + return this.#submitCreation("googleSheet", options); + } + + createFolder(options: DriveCreationOptions): Promise { + return this.#submitCreation("folder", options); + } + + async getCreationResult(handle: DriveCreationHandle): Promise { + let state = readDriveCreationState(this.#storage, handle.id); + if (state.status !== "created") return state; + return { + status: "created", + kind: state.kind, + entry: await this.#core.getEntry(state.fileId), + }; + } + + async #submitCreation( + kind: DriveCreationKind, options: DriveCreationOptions, + ): Promise { + if (this.#scope.kind === "file") { + throw new Error("The requested file is outside this Drive binding."); + } + validateDriveCreationName(options.name); + assertDriveCreationCapacity(this.#storage); + let parent = await this.#core.resolveCreationParent(options.parentId); + return submitDriveCreation({ + storage: this.#storage, + approvalQueue: this.#approvalQueue, + kind, + name: options.name, + parent, + }); + } + async openGoogleDoc(fileId: string): Promise { let documentId = await this.#core.openNativeFile( fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index e4649acc6..915cdf688 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -70,19 +70,19 @@ export const BIGQUERY_RESOURCE: SupportedResource = { grantable: true, }; -/** Files, folders, and read-only native content visible to the connected Google Drive account. */ +/** Files, read-only native content, and blank item creation across one Drive account. */ export const GOOGLE_DRIVE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/drive/my-drive", title: "Google Drive Account", - description: "Find files and folders, and read native Google Docs and Sheets, in My Drive or Shared with me.", + description: "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in My Drive or Shared with me.", grantable: true, }; -/** Files, folders, and read-only native content in one Google Workspace shared drive. */ +/** Files, read-only native content, and blank item creation in one Workspace shared drive. */ export const GOOGLE_SHARED_DRIVE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/drive/folders/:driveId", title: "Google Workspace Shared Drive", - description: "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", + description: "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, and folders in one organization-owned shared drive.", grantable: true, }; From 60a28b89d3dd3846bc4d5ac119db9e0a3075843f Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Fri, 21 Aug 2026 17:39:32 -0500 Subject: [PATCH 6/6] fix(google): harden Drive creation lifecycle --- .../__tests__/drive-api.test.ts | 56 ++++++- .../__tests__/drive-creation.test.ts | 138 ++++++++++++++++- .../__tests__/drive-session.test.ts | 66 ++++---- .../gatekeeper-google/__tests__/types.test.ts | 1 - .../__tests__/workerd/native-sessions.test.ts | 2 +- packages/gatekeeper-google/src/drive-api.ts | 48 ++++-- .../gatekeeper-google/src/drive-creation.ts | 142 +++++++++++++----- .../gatekeeper-google/src/drive-session.ts | 31 ++-- .../gatekeeper-google/src/drive-types.d.ts | 12 +- .../gatekeeper-google/src/drive-types.txt | 11 +- packages/gatekeeper-google/src/google.ts | 50 +----- .../src/pending-action-store.ts | 50 ++++++ 12 files changed, 451 insertions(+), 156 deletions(-) create mode 100644 packages/gatekeeper-google/src/pending-action-store.ts diff --git a/packages/gatekeeper-google/__tests__/drive-api.test.ts b/packages/gatekeeper-google/__tests__/drive-api.test.ts index 6dd321871..d79036aa7 100644 --- a/packages/gatekeeper-google/__tests__/drive-api.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-api.test.ts @@ -42,7 +42,7 @@ const CREATION_REQUEST_ID = "123e4567-e89b-42d3-a456-426614174000"; const DRIVE_FILE_ITEM_FIELDS = [ "id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId", "owners(displayName,emailAddress)", "webViewLink", - "shortcutDetails(targetId,targetMimeType)", "trashed", + "shortcutDetails(targetId,targetMimeType)", "trashed", "appProperties", "capabilities(canAddChildren,canTrash)", ].join(","); @@ -266,6 +266,7 @@ describe("creation mutations", () => { ] as const)("creates a metadata-only %s in one resolved parent", async (name, mimeType) => { let created = { id: `created-${name}`, name, mimeType, parents: ["parent-1"], trashed: false, + appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID }, capabilities: { canAddChildren: mimeType.endsWith("folder"), canTrash: true }, }; let calls = stubFetch([jsonResponse(created)]); @@ -319,6 +320,26 @@ describe("creation mutations", () => { expect(calls[0].body).toBe(calls[1].body); }); + it.each([429, 503])("does not transiently replay a create after HTTP %i", async status => { + let calls = stubFetch([new Response("retry later", { status })]); + + await expect(api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).rejects.toThrow(`Google Drive API request failed: ${status}`); + expect(calls).toHaveLength(1); + }); + + it("does not replay a create after a network failure", async () => { + let calls = stubFetch(() => { throw new Error("network unavailable"); }); + + await expect(api().createFile({ + name: "Plan", mimeType: "application/vnd.google-apps.document", + parentId: "parent-1", requestId: CREATION_REQUEST_ID, + })).rejects.toThrow("network unavailable"); + expect(calls).toHaveLength(1); + }); + it("rejects malformed create metadata instead of trusting it", async () => { stubFetch([jsonResponse({ id: 42, name: "Plan" })]); @@ -361,7 +382,9 @@ describe("creation mutations", () => { it("finds one prior create only through its private generated marker", async () => { let found = { id: "created-1", name: "Plan", mimeType: "application/vnd.google-apps.document", - parents: ["parent-1"], trashed: false, capabilities: { canTrash: true }, + parents: ["parent-1"], trashed: false, + appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID }, + capabilities: { canTrash: true }, }; let calls = stubFetch([jsonResponse({ files: [found] })]); @@ -379,6 +402,29 @@ describe("creation mutations", () => { expect(params.get("fields")).toBe(`nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`); }); + it("follows short marker pages until the result set is exhausted", async () => { + let found = { id: "created-1", name: "Plan" }; + let calls = stubFetch([ + jsonResponse({ files: [found], nextPageToken: "next-page" }), + jsonResponse({ files: [] }), + ]); + + await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)).resolves.toEqual(found); + expect(calls).toHaveLength(2); + expect(calls[1].url.searchParams.get("pageToken")).toBe("next-page"); + }); + + it("fails closed when marker matches are split across pages", async () => { + let calls = stubFetch([ + jsonResponse({ files: [{ id: "created-1", name: "Plan" }], nextPageToken: "next-page" }), + jsonResponse({ files: [{ id: "created-2", name: "Plan" }] }), + ]); + + await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)) + .rejects.toThrow("Multiple Google Drive files matched one creation request"); + expect(calls).toHaveLength(2); + }); + it("returns no prior create when the generated marker is absent", async () => { stubFetch([jsonResponse({ files: [] })]); await expect(api().findFileByCreationRequestId(CREATION_REQUEST_ID)) @@ -412,6 +458,12 @@ describe("creation mutations", () => { expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS); expect(JSON.parse(calls[0].body ?? "")).toEqual({ trashed: true }); }); + + it("rejects a trash response whose postcondition is false", async () => { + stubFetch([jsonResponse({ id: "created-1", name: "Plan", trashed: false })]); + await expect(api().trashFile("created-1")) + .rejects.toThrow("Google Drive did not trash the requested file"); + }); }); describe("bulk access verification", () => { diff --git a/packages/gatekeeper-google/__tests__/drive-creation.test.ts b/packages/gatekeeper-google/__tests__/drive-creation.test.ts index 1ac5455c8..2ed6c9eff 100644 --- a/packages/gatekeeper-google/__tests__/drive-creation.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-creation.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { ApprovalQueue } from "@gadgets/workshop-shared/gatekeeper"; import { - applyDriveCreation, DriveCreationStore, readDriveCreationState, rejectDriveCreation, - revertDriveCreation, submitDriveCreation, + applyDriveCreation, DriveCreationCoordinator, DriveCreationStore, readDriveCreationState, + rejectDriveCreation, revertDriveCreation, submitDriveCreation, type DriveCreationApi, type DriveCreationStorage, } from "../src/drive-creation"; import type { DriveFile } from "../src/drive-api"; @@ -55,6 +55,7 @@ const parent = (overrides: Partial = {}): DriveFile => file({ name: "Plans", mimeType: FOLDER_MIME_TYPE, parents: ["root"], + appProperties: { gadgetsCreationRequestId: REQUEST_ID }, capabilities: { canAddChildren: true }, ...overrides, }); @@ -73,6 +74,7 @@ const action = { kind: "googleDoc" as const, name: "Quarterly plan", parentId: "parent-1", + parentAuthority: "appCreated" as const, requestId: REQUEST_ID, }; @@ -88,7 +90,7 @@ async function submit( approvalQueue, kind: overrides.kind ?? "googleDoc", name: overrides.name ?? "Quarterly plan", - parent: { id: "parent-1", name: "Plans" }, + parent: { id: "parent-1", name: "Plans", authority: "appCreated" }, requestId: REQUEST_ID, }); } @@ -105,7 +107,8 @@ describe("Drive creation submission", () => { id: 1, kind: "googleDoc", name, }); expect(new DriveCreationStore(storage).getAction(1)).toEqual({ - kind: "googleDoc", name, parentId: "parent-1", requestId: REQUEST_ID, + kind: "googleDoc", name, parentId: "parent-1", parentAuthority: "appCreated", + requestId: REQUEST_ID, }); expect(approvalQueue.submitAction).toHaveBeenCalledTimes(1); let [id, description] = approvalQueue.submitAction.mock.calls[0]!; @@ -157,7 +160,9 @@ describe("Drive creation action lifecycle", () => { let handle = await submit(storage); expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "pending" }); - rejectDriveCreation(storage, handle.id); + await rejectDriveCreation( + { storage, api: fakeApi(), scope: { kind: "account" } }, handle.id, + ); expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "rejected" }); expect(new DriveCreationStore(storage).getAction(handle.id)).toBeUndefined(); @@ -165,7 +170,7 @@ describe("Drive creation action lifecycle", () => { .toBeLessThan(storage.events.indexOf("delete:pending:action:1")); }); - it("stores provider failure while keeping the action retryable", async () => { + it("reports a failed attempt as retryable pending state", async () => { let storage = new FakeKv(); let handle = await submit(storage); let api = fakeApi({ @@ -176,11 +181,115 @@ describe("Drive creation action lifecycle", () => { { storage, api, scope: { kind: "account" } }, handle.id, )).rejects.toThrow("provider unavailable"); expect(readDriveCreationState(storage, handle.id)).toEqual({ - status: "failed", message: "provider unavailable", + status: "pending", lastError: "provider unavailable", }); expect(new DriveCreationStore(storage).getAction(handle.id)).toEqual(action); }); + it("serializes concurrent apply callbacks for one creation", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let resolveCreate!: (value: DriveFile) => void; + let createFile = vi.fn(() => new Promise(resolve => { resolveCreate = resolve; })); + let api = fakeApi({ createFile }); + let coordinator = new DriveCreationCoordinator(); + let runtime = { storage, api, scope: { kind: "account" } as const }; + + let first = coordinator.apply(runtime, handle.id); + let second = coordinator.apply(runtime, handle.id); + await vi.waitFor(() => expect(createFile).toHaveBeenCalledTimes(1)); + resolveCreate(file()); + await Promise.all([first, second]); + + expect(createFile).toHaveBeenCalledTimes(1); + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + }); + + it("recovers an apply left durably in progress after an instance restart", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let store = new DriveCreationStore(storage); + store.putApplying(handle.id); + let api = fakeApi(); + + await applyDriveCreation({ storage, api, scope: { kind: "account" } }, handle.id); + + expect(api.findFileByCreationRequestId).toHaveBeenCalledTimes(1); + expect(api.createFile).toHaveBeenCalledTimes(1); + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + }); + + it("refuses rejection while the same creation is being applied", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let resolveCreate!: (value: DriveFile) => void; + let api = fakeApi({ + createFile: vi.fn(() => new Promise(resolve => { resolveCreate = resolve; })), + }); + let runtime = { storage, api, scope: { kind: "account" } as const }; + let applying = new DriveCreationCoordinator().apply(runtime, handle.id); + await vi.waitFor(() => expect(api.createFile).toHaveBeenCalledTimes(1)); + + await expect(rejectDriveCreation(runtime, handle.id)).rejects.toThrow(/currently being applied/); + resolveCreate(file()); + await applying; + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + }); + + it("serializes rejection behind an in-flight apply callback", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let resolveCreate!: (value: DriveFile) => void; + let api = fakeApi({ + createFile: vi.fn(() => new Promise(resolve => { resolveCreate = resolve; })), + }); + let coordinator = new DriveCreationCoordinator(); + let runtime = { storage, api, scope: { kind: "account" } as const }; + let applying = coordinator.apply(runtime, handle.id); + await vi.waitFor(() => expect(api.createFile).toHaveBeenCalledTimes(1)); + + let rejecting = coordinator.reject(runtime, handle.id); + resolveCreate(file()); + await applying; + await expect(rejecting).rejects.toThrow(/already been applied/); + expect(api.createFile).toHaveBeenCalledTimes(1); + expect(readDriveCreationState(storage, handle.id).status).toBe("created"); + }); + + it("retains and cleans up a created file when response validation fails", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi({ createFile: vi.fn(async () => file({ name: "Unexpected" })) }); + let runtime = { storage, api, scope: { kind: "account" } as const }; + + await expect(applyDriveCreation(runtime, handle.id)) + .rejects.toThrow("creation marker matched unexpected file metadata"); + expect(new DriveCreationStore(storage).getOutcome(handle.id)).toEqual({ + status: "failed", + message: "Google Drive creation marker matched unexpected file metadata", + createdFileId: "created-1", + }); + + await rejectDriveCreation(runtime, handle.id); + expect(api.trashFile).toHaveBeenCalledWith("created-1"); + expect(readDriveCreationState(storage, handle.id)).toEqual({ status: "rejected" }); + }); + + it("preserves a known created file ID when a later retry fails early", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi({ createFile: vi.fn(async () => file({ name: "Unexpected" })) }); + let runtime = { storage, api, scope: { kind: "account" } as const }; + await expect(applyDriveCreation(runtime, handle.id)).rejects.toThrow(); + api.getFile = vi.fn(async () => { throw new Error("parent unavailable"); }); + + await expect(applyDriveCreation(runtime, handle.id)).rejects.toThrow("parent unavailable"); + + expect(new DriveCreationStore(storage).getOutcome(handle.id)).toEqual({ + status: "failed", message: "parent unavailable", createdFileId: "created-1", + }); + }); + it("records creation before removing pending state and trashes it on revert", async () => { let storage = new FakeKv(); let handle = await submit(storage); @@ -264,10 +373,23 @@ describe("Drive creation action lifecycle", () => { await expect(applyDriveCreation({ storage, api, scope }, handle.id)) .rejects.toThrow("creation marker matched unexpected file metadata"); expect(createFile).not.toHaveBeenCalled(); - expect(readDriveCreationState(storage, handle.id).status).toBe("failed"); + expect(readDriveCreationState(storage, handle.id).status).toBe("pending"); expect(new DriveCreationStore(storage).getAction(handle.id)).toBeDefined(); }); + it("rejects a file-scoped apply callback before marker lookup or creation", async () => { + let storage = new FakeKv(); + let handle = await submit(storage); + let api = fakeApi(); + + await expect(applyDriveCreation( + { storage, api, scope: { kind: "file", fileId: "parent-1" } }, handle.id, + )).rejects.toThrow(/outside this Drive binding/); + expect(api.getFile).not.toHaveBeenCalled(); + expect(api.findFileByCreationRequestId).not.toHaveBeenCalled(); + expect(api.createFile).not.toHaveBeenCalled(); + }); + it("fails before marker lookup when the approved parent moved out of scope", async () => { let storage = new FakeKv(); let handle = await submit(storage); diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index d7a1c71d3..62a759f51 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -163,7 +163,7 @@ describe("Drive creation parent authorization", () => { getFile: async id => { events.push(`fetch:${id}`); return file({ - id: "root-id", name: "My Drive", mimeType: FOLDER_MIME_TYPE, + id: "root-id", name: "My Drive", mimeType: FOLDER_MIME_TYPE, trashed: false, capabilities: { canAddChildren: true }, }); }, @@ -175,7 +175,7 @@ describe("Drive creation parent authorization", () => { }); await expect(session.resolveCreationParent()).resolves.toEqual({ - id: "root-id", name: "My Drive", + id: "root-id", name: "My Drive", authority: "root", }); expect(getFile).toHaveBeenCalledWith("root"); expect(events).toEqual(["fetch:root", "prepare:root-id", "authorize", "commit"]); @@ -185,29 +185,55 @@ describe("Drive creation parent authorization", () => { let { session, getFile, prepared } = core({ scope: { kind: "sharedDrive", driveId: "drive-1" }, getFile: async id => file({ - id, name: "Team Drive", mimeType: FOLDER_MIME_TYPE, + id, name: "Team Drive", mimeType: FOLDER_MIME_TYPE, trashed: false, capabilities: { canAddChildren: true }, }), }); await expect(session.resolveCreationParent()).resolves.toEqual({ - id: "drive-1", name: "Team Drive", + id: "drive-1", name: "Team Drive", authority: "root", }); expect(getFile).toHaveBeenCalledWith("drive-1"); expect(prepared).toEqual([["drive-1"]]); }); - it("fetches an explicit nested folder ID exactly", async () => { + it("accepts an explicit folder created by this app", async () => { let { session, getFile } = core({ getFile: async id => file({ - id, name: "Nested", mimeType: FOLDER_MIME_TYPE, + id, name: "Nested", mimeType: FOLDER_MIME_TYPE, trashed: false, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, capabilities: { canAddChildren: true }, }), }); - await expect(session.resolveCreationParent(" folder-with-spaces ")) - .resolves.toEqual({ id: " folder-with-spaces ", name: "Nested" }); - expect(getFile).toHaveBeenCalledWith(" folder-with-spaces "); + await expect(session.resolveCreationParent("folder-with-spaces")) + .resolves.toEqual({ id: "folder-with-spaces", name: "Nested", authority: "appCreated" }); + expect(getFile).toHaveBeenCalledWith("folder-with-spaces"); + }); + + it("rejects an explicit folder that was not authorized for drive.file", async () => { + let { session } = core({ + getFile: async id => file({ + id, name: "Nested", mimeType: FOLDER_MIME_TYPE, trashed: false, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent("existing-folder")) + .rejects.toThrow(/created by this app/); + }); + + it("rejects a trashed creation parent", async () => { + let { session } = core({ + getFile: async id => file({ + id, name: "Deleted", mimeType: FOLDER_MIME_TYPE, trashed: true, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent("deleted-folder")) + .rejects.toThrow(/trashed/); }); it("rejects an empty explicit parent ID before provider access", async () => { @@ -245,7 +271,8 @@ describe("Drive creation parent authorization", () => { async canAddChildren => { let { session, prepared } = core({ getFile: async id => file({ - id, mimeType: FOLDER_MIME_TYPE, + id, mimeType: FOLDER_MIME_TYPE, trashed: false, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, capabilities: canAddChildren === undefined ? {} : { canAddChildren }, }), }); @@ -270,27 +297,14 @@ describe("Drive creation parent authorization", () => { expect(prepared).toEqual([]); }); - it("fails revalidation when an approved parent moves outside the binding", async () => { - let request = 0; - let { session } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ - id, driveId: request++ === 0 ? "drive-1" : "drive-2", - mimeType: FOLDER_MIME_TYPE, capabilities: { canAddChildren: true }, - }), - }); - - await expect(session.resolveCreationParent("folder-1")) - .resolves.toEqual({ id: "folder-1", name: "Quarterly plan" }); - await expect(session.revalidateCreationParent("folder-1")) - .rejects.toThrow(/outside this Drive binding/); - }); it("does not commit the parent observation when authorization fails", async () => { let committed = false; let { session } = core({ getFile: async id => file({ - id, mimeType: FOLDER_MIME_TYPE, capabilities: { canAddChildren: true }, + id, mimeType: FOLDER_MIME_TYPE, trashed: false, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, + capabilities: { canAddChildren: true }, }), prepareObservation: async ids => ({ pendingSets: ids, commit: () => { committed = true; }, diff --git a/packages/gatekeeper-google/__tests__/types.test.ts b/packages/gatekeeper-google/__tests__/types.test.ts index cd64e711d..89d0e759c 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -66,7 +66,6 @@ describe("embedded agent declarations", () => { it("keeps the Drive declaration aligned after module-only imports", () => { const modulePrefix = - 'import type { RpcTarget } from "cloudflare:workers";\n' + 'import type { GoogleDocReadSession } from "./docs-read-types";\n' + 'import type { GoogleSpreadsheetSession } from "./sheets-types";\n\n'; const driveTypes = source("drive-types.d.ts"); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index efce921ff..bc13e1ecf 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -199,7 +199,7 @@ describe("Drive nested native sessions", () => { describe("Drive creation RPC", () => { it("round-trips handles and authoritative created outcomes through a real RPC stub", async () => { const { driveApi, queue, session, storage } = newSession(); - const rpc: RpcStub = new RpcStub(session); + const rpc = new RpcStub(session); const doc = await rpc.createGoogleDoc({ name: "Quarterly plan" }); const sheet = await rpc.createGoogleSheet({ name: "Forecast" }); diff --git a/packages/gatekeeper-google/src/drive-api.ts b/packages/gatekeeper-google/src/drive-api.ts index 72219c11f..b6fc565b9 100644 --- a/packages/gatekeeper-google/src/drive-api.ts +++ b/packages/gatekeeper-google/src/drive-api.ts @@ -10,6 +10,7 @@ const MAX_BATCH_RESPONSE_BYTES = 1_000_000; const MAX_JSON_RESPONSE_BYTES = 5_000_000; const DRIVE_API_TIMEOUT_MS = 30_000; const CREATION_REQUEST_PROPERTY = "gadgetsCreationRequestId"; +const MAX_CREATION_MARKER_PAGES = 100; const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const logger = obsContext.createLogger({ @@ -29,16 +30,23 @@ export type DriveFile = { webViewLink?: string; shortcutDetails?: { targetId?: string; targetMimeType?: string }; trashed?: boolean; + appProperties?: { gadgetsCreationRequestId?: string }; capabilities?: { canAddChildren?: boolean; canTrash?: boolean }; }; +/** Whether current metadata proves that this OAuth client created the file. */ +export function hasDriveCreationMarker(file: DriveFile): boolean { + let requestId = file.appProperties?.[CREATION_REQUEST_PROPERTY]; + return requestId !== undefined && UUID_V4_PATTERN.test(requestId); +} + /** Current metadata for one shared drive. */ export type DriveInfo = { id: string; name: string }; const DRIVE_FILE_ITEM_FIELDS = [ "id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId", "owners(displayName,emailAddress)", "webViewLink", - "shortcutDetails(targetId,targetMimeType)", "trashed", + "shortcutDetails(targetId,targetMimeType)", "trashed", "appProperties", "capabilities(canAddChildren,canTrash)", ].join(","); @@ -221,6 +229,14 @@ function parseDriveFile(value: unknown): DriveFile { } let parents = optionalStringArray(value.parents, "file parents"); let trashed = optionalBoolean(value.trashed, "file trashed state"); + let appProperties: DriveFile["appProperties"]; + if (value.appProperties !== undefined) { + if (!isRecord(value.appProperties)) throw new Error("Invalid Google Drive app properties"); + let requestId = optionalString( + value.appProperties[CREATION_REQUEST_PROPERTY], "creation request app property", + ); + appProperties = requestId ? { [CREATION_REQUEST_PROPERTY]: requestId } : {}; + } let capabilities = parseDriveCapabilities(value.capabilities); return { id: value.id, @@ -232,6 +248,7 @@ function parseDriveFile(value: unknown): DriveFile { ...(owners ? { owners } : {}), ...(shortcutDetails ? { shortcutDetails } : {}), ...(trashed === undefined ? {} : { trashed }), + ...(appProperties ? { appProperties } : {}), ...(capabilities ? { capabilities } : {}), }; } @@ -350,18 +367,25 @@ export class DriveApi { supportsAllDrives: "true", includeItemsFromAllDrives: "true", }); - let body = await this.#getUnknown("/files", params, "find created file"); - if (!isRecord(body)) throw new Error("Invalid Google Drive file-list response"); - let files: DriveFile[] = []; - if (body.files !== undefined) { - if (!Array.isArray(body.files)) throw new Error("Invalid Google Drive file-list response"); - files = body.files.map(parseDriveFile); - } - let nextPageToken = optionalString(body.nextPageToken, "nextPageToken"); - if (files.length > 1 || nextPageToken !== undefined) { - throw new Error("Multiple Google Drive files matched one creation request"); + let found: DriveFile | undefined; + for (let page = 0; page < MAX_CREATION_MARKER_PAGES; page++) { + let body = await this.#getUnknown("/files", params, "find created file"); + if (!isRecord(body)) throw new Error("Invalid Google Drive file-list response"); + if (body.files !== undefined) { + if (!Array.isArray(body.files)) throw new Error("Invalid Google Drive file-list response"); + for (let value of body.files) { + let file = parseDriveFile(value); + if (found) { + throw new Error("Multiple Google Drive files matched one creation request"); + } + found = file; + } + } + let nextPageToken = optionalString(body.nextPageToken, "nextPageToken"); + if (!nextPageToken) return found; + params.set("pageToken", nextPageToken); } - return files[0]; + throw new Error("Google Drive creation marker lookup exceeded its page limit"); } /** Move one Drive item to trash. */ diff --git a/packages/gatekeeper-google/src/drive-creation.ts b/packages/gatekeeper-google/src/drive-creation.ts index 223b83e0f..f3aafa4d2 100644 --- a/packages/gatekeeper-google/src/drive-creation.ts +++ b/packages/gatekeeper-google/src/drive-creation.ts @@ -6,10 +6,9 @@ import { type DriveBindingScope, type DriveCreationParent, } from "./drive-session"; import type { DriveCreationHandle, DriveCreationKind } from "./drive-types"; +import { PendingActionStore, type PendingActionStorage } from "./pending-action-store"; import { obsContext } from "./observability"; -const ACTION_PREFIX = "pending:action:"; -const NEXT_ACTION_ID_KEY = "pending:nextActionId"; const OUTCOME_PREFIX = "drive:create:outcome:"; const NEXT_OUTCOME_SEQUENCE_KEY = "drive:create:nextOutcomeSequence"; const MAX_PENDING_CREATIONS = 100; @@ -32,12 +31,7 @@ const logger = obsContext.createLogger({ }); /** Synchronous Durable Object KV operations used by Drive creation state. */ -export interface DriveCreationStorage { - get(key: string): T | undefined; - put(key: string, value: T): void; - delete(key: string): void; - list(options: { prefix: string }): Iterable<[string, T]>; -} +export type DriveCreationStorage = PendingActionStorage; /** Narrow provider surface used by creation callbacks. */ export type DriveCreationApi = Pick< @@ -49,20 +43,24 @@ export type DriveCreationAction = { kind: DriveCreationKind; name: string; parentId: string; + parentAuthority: DriveCreationParent["authority"]; requestId: string; }; /** Persisted callback outcome; provider metadata is intentionally represented only by file ID. */ export type StoredDriveCreationOutcome = + | { status: "applying"; createdFileId?: string } | { status: "rejected" } - | { status: "failed"; message: string } + | { status: "failed"; message: string; createdFileId?: string } | { status: "created"; kind: DriveCreationKind; fileId: string } | { status: "reverted" }; /** Current authoritative state before created metadata is freshly observed. */ export type StoredDriveCreationState = - | { status: "pending" } - | StoredDriveCreationOutcome; + | { status: "pending"; lastError?: string } + | { status: "rejected" } + | { status: "created"; kind: DriveCreationKind; fileId: string } + | { status: "reverted" }; type StoredOutcomeRecord = { sequence: number; @@ -71,33 +69,42 @@ type StoredOutcomeRecord = { /** Durable action and bounded outcome storage for one Drive binding. */ export class DriveCreationStore { - constructor(private storage: DriveCreationStorage) {} + #actions: PendingActionStore; + + constructor(private storage: DriveCreationStorage) { + this.#actions = new PendingActionStore(storage); + } submit(action: DriveCreationAction): number { - let id = this.storage.get(NEXT_ACTION_ID_KEY) ?? 1; - this.storage.put(NEXT_ACTION_ID_KEY, id + 1); - this.storage.put(this.#actionKey(id), action); - return id; + return this.#actions.submit(action); } pendingCount(): number { - return [...this.storage.list({ prefix: ACTION_PREFIX })].length; + return this.#actions.list().length; } getAction(id: number): DriveCreationAction | undefined { - return this.storage.get(this.#actionKey(id)); + return this.#actions.get(id); } removeAction(id: number): void { - this.storage.delete(this.#actionKey(id)); + this.#actions.remove(id); } getOutcome(id: number): StoredDriveCreationOutcome | undefined { return this.storage.get(this.#outcomeKey(id))?.outcome; } - putFailure(id: number, message: string): void { - this.#putOutcome(id, { status: "failed", message }); + putApplying(id: number, createdFileId?: string): void { + this.#putOutcome(id, { + status: "applying", ...(createdFileId ? { createdFileId } : {}), + }); + } + + putFailure(id: number, message: string, createdFileId?: string): void { + this.#putOutcome(id, { + status: "failed", message, ...(createdFileId ? { createdFileId } : {}), + }); } finish(id: number, outcome: Exclude): void { @@ -111,10 +118,6 @@ export class DriveCreationStore { this.#pruneTerminalOutcomes(); } - #actionKey(id: number): string { - return `${ACTION_PREFIX}${id}`; - } - #outcomeKey(id: number): string { return `${OUTCOME_PREFIX}${id}`; } @@ -167,6 +170,7 @@ export async function submitDriveCreation(options: { kind: options.kind, name: options.name, parentId: options.parent.id, + parentAuthority: options.parent.authority, requestId: options.requestId ?? crypto.randomUUID(), }; let store = new DriveCreationStore(options.storage); @@ -191,7 +195,8 @@ export async function submitDriveCreation(options: { return { id, kind: action.kind, name: action.name }; } -type DriveCreationRuntime = { +/** Provider and durable state required by Drive creation callbacks. */ +export type DriveCreationRuntime = { storage: DriveCreationStorage; api: DriveCreationApi; scope: DriveBindingScope; @@ -203,6 +208,10 @@ export function readDriveCreationState( ): StoredDriveCreationState { let store = new DriveCreationStore(storage); let outcome = store.getOutcome(actionId); + if (outcome?.status === "failed") { + return { status: "pending", lastError: outcome.message }; + } + if (outcome?.status === "applying") return { status: "pending" }; if (outcome) return outcome; if (store.getAction(actionId)) return { status: "pending" }; throw new Error(`Unknown Google Drive creation action: ${actionId}`); @@ -212,19 +221,26 @@ export function readDriveCreationState( export async function applyDriveCreation( runtime: DriveCreationRuntime, actionId: number, ): Promise { + if (runtime.scope.kind === "file") { + throw new Error("The requested file is outside this Drive binding."); + } let store = new DriveCreationStore(runtime.storage); let outcome = store.getOutcome(actionId); - if (outcome && outcome.status !== "failed") { + if (outcome && outcome.status !== "failed" && outcome.status !== "applying") { store.cleanupTerminal(actionId); return; } let action = store.getAction(actionId); if (!action) throw new Error(`Unknown pending Google Drive creation action: ${actionId}`); + let knownCreatedFileId = outcome?.status === "failed" || outcome?.status === "applying" + ? outcome.createdFileId + : undefined; + store.putApplying(actionId, knownCreatedFileId); - let created: DriveFile; + let created: DriveFile | undefined; try { let parent = await runtime.api.getFile(action.parentId); - validateDriveCreationParent(runtime.scope, parent); + validateDriveCreationParent(runtime.scope, parent, action.parentAuthority); created = await runtime.api.findFileByCreationRequestId(action.requestId) ?? await runtime.api.createFile({ name: action.name, @@ -234,7 +250,7 @@ export async function applyDriveCreation( }); validateCreatedFile(runtime.scope, action, created); } catch (error) { - store.putFailure(actionId, failureMessage(error)); + store.putFailure(actionId, failureMessage(error), created?.id ?? knownCreatedFileId); logger.warn("Drive creation action failed", { event: "drive.creation.apply.failed", actionId, operation: "apply", error, }); @@ -244,15 +260,58 @@ export async function applyDriveCreation( store.finish(actionId, { status: "created", kind: action.kind, fileId: created.id }); } -/** Record rejection before removing retryable pending state. */ -export function rejectDriveCreation(storage: DriveCreationStorage, actionId: number): void { - let store = new DriveCreationStore(storage); +/** Serialize callbacks for each action while retaining crash recovery in durable state. */ +export class DriveCreationCoordinator { + #inFlight = new Map>(); + + /** Apply one approved creation after any earlier callback for the same action. */ + apply(runtime: DriveCreationRuntime, actionId: number): Promise { + return this.#run(actionId, () => applyDriveCreation(runtime, actionId)); + } + + /** Reject one creation after any earlier callback for the same action. */ + reject(runtime: DriveCreationRuntime, actionId: number): Promise { + return this.#run(actionId, () => rejectDriveCreation(runtime, actionId)); + } + + /** Revert one creation after any earlier callback for the same action. */ + revert(runtime: DriveCreationRuntime, actionId: number): Promise { + return this.#run(actionId, () => revertDriveCreation(runtime, actionId)); + } + + #run(actionId: number, operation: () => Promise): Promise { + let previous = this.#inFlight.get(actionId) ?? Promise.resolve(); + let current = previous.catch(() => {}).then(operation).finally(() => { + if (this.#inFlight.get(actionId) === current) this.#inFlight.delete(actionId); + }); + this.#inFlight.set(actionId, current); + return current; + } +} + +/** Reject pending creation, first removing any file produced by a failed attempt. */ +export async function rejectDriveCreation( + runtime: DriveCreationRuntime, actionId: number, +): Promise { + let store = new DriveCreationStore(runtime.storage); let outcome = store.getOutcome(actionId); - if (outcome && outcome.status !== "failed") { + if (outcome?.status === "rejected") { store.cleanupTerminal(actionId); return; } - if (store.getAction(actionId)) store.finish(actionId, { status: "rejected" }); + if (outcome?.status === "applying") { + throw new Error(`Google Drive creation action ${actionId} is currently being applied`); + } + if (outcome?.status === "created" || outcome?.status === "reverted") { + throw new Error(`Google Drive creation action ${actionId} has already been applied`); + } + if (!store.getAction(actionId)) { + throw new Error(`Unknown pending Google Drive creation action: ${actionId}`); + } + if (outcome?.status === "failed" && outcome.createdFileId) { + await trashCreatedFile(runtime, outcome.createdFileId); + } + store.finish(actionId, { status: "rejected" }); } /** Trash a currently authorized created item and record its reverted state. */ @@ -262,10 +321,18 @@ export async function revertDriveCreation( let store = new DriveCreationStore(runtime.storage); let outcome = store.getOutcome(actionId); if (outcome?.status === "reverted") return; - if (outcome?.status !== "created") { + let fileId: string | undefined; + if (outcome?.status === "created") fileId = outcome.fileId; + else if (outcome?.status === "failed") fileId = outcome.createdFileId; + if (!fileId) { throw new Error(`Google Drive creation action ${actionId} cannot be reverted`); } - let file = await runtime.api.getFile(outcome.fileId); + await trashCreatedFile(runtime, fileId); + store.finish(actionId, { status: "reverted" }); +} + +async function trashCreatedFile(runtime: DriveCreationRuntime, fileId: string): Promise { + let file = await runtime.api.getFile(fileId); if (runtime.scope.kind === "file" || !isDriveFileInScope(runtime.scope, file)) { throw new Error("The requested file is outside this Drive binding."); } @@ -273,7 +340,6 @@ export async function revertDriveCreation( throw new Error("The created Google Drive item cannot currently be moved to trash"); } await runtime.api.trashFile(file.id); - store.finish(actionId, { status: "reverted" }); } function validateCreatedFile( diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index d93f56dff..c08be9c3e 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -1,6 +1,6 @@ import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; import { CursorPager, type Pager } from "./cursor"; -import type { DriveApi, DriveFile, DriveListFilesOptions } from "./drive-api"; +import { hasDriveCreationMarker, type DriveApi, type DriveFile, type DriveListFilesOptions } from "./drive-api"; import type { ObserverCheck } from "./observers"; import type { DriveEntry, DriveListOptions, DriveOrder, DriveScope, DriveSearchQuery, @@ -19,8 +19,15 @@ export type DriveBindingScope = | { kind: "sharedDrive"; driveId: string } | { kind: "file"; fileId: string }; +/** How a creation destination is authorized under the per-file OAuth grant. */ +export type DriveCreationParentAuthority = "root" | "appCreated"; + /** Canonical destination metadata safe to persist after observation authorization. */ -export type DriveCreationParent = { id: string; name: string }; +export type DriveCreationParent = { + id: string; + name: string; + authority: DriveCreationParentAuthority; +}; /** Whether current provider metadata remains inside immutable binding authority. */ export function isDriveFileInScope(scope: DriveBindingScope, file: DriveFile): boolean { @@ -33,7 +40,7 @@ export function isDriveFileInScope(scope: DriveBindingScope, file: DriveFile): b /** Validate current provider metadata as a writable creation destination. */ export function validateDriveCreationParent( - scope: DriveBindingScope, file: DriveFile, + scope: DriveBindingScope, file: DriveFile, authority: DriveCreationParentAuthority, ): DriveCreationParent { if (scope.kind === "file" || !isDriveFileInScope(scope, file)) { throw new Error("The requested file is outside this Drive binding."); @@ -41,10 +48,16 @@ export function validateDriveCreationParent( if (file.mimeType !== FOLDER_MIME_TYPE) { throw new Error("Drive creation parent must identify a folder"); } + if (file.trashed !== false) { + throw new Error("Drive creation parent is trashed"); + } + if (authority === "appCreated" && !hasDriveCreationMarker(file)) { + throw new Error("Drive creation parent must be the root or a folder created by this app"); + } if (file.capabilities?.canAddChildren !== true) { throw new Error("Drive creation parent does not allow adding children"); } - return { id: file.id, name: file.name }; + return { id: file.id, name: file.name, authority }; } type DriveSessionApi = Pick; @@ -196,7 +209,8 @@ export class DriveSessionCore { let requestedId = parentId ?? (this.#scope.kind === "sharedDrive" ? this.#scope.driveId : "root"); let parent = await this.#api.getFile(requestedId); - let resolved = validateDriveCreationParent(this.#scope, parent); + let authority: DriveCreationParentAuthority = parentId === undefined ? "root" : "appCreated"; + let resolved = validateDriveCreationParent(this.#scope, parent, authority); await this.#authorizeIds( [parent.id], "Check Google Drive creation destination", @@ -205,13 +219,6 @@ export class DriveSessionCore { return resolved; } - /** Re-fetch and validate a previously resolved creation destination before mutation. */ - async revalidateCreationParent(parentId: string): Promise { - if (this.#scope.kind === "file") this.#outsideScope(); - if (!parentId.trim()) throw new Error("parentId must not be empty"); - return validateDriveCreationParent(this.#scope, await this.#api.getFile(parentId)); - } - async list(options: DriveListOptions = {}): Promise> { if (options.directParentId) await this.#assertParent(options.directParentId); if (this.#scope.kind === "file") return this.#exactFileCursor(); diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index 22b9383a5..ca226af78 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -1,4 +1,3 @@ -import type { RpcTarget } from "cloudflare:workers"; import type { GoogleDocReadSession } from "./docs-read-types"; import type { GoogleSpreadsheetSession } from "./sheets-types"; @@ -117,7 +116,7 @@ export type DriveCreationKind = "googleDoc" | "googleSheet" | "folder"; export interface DriveCreationOptions { /** Non-empty name for the new item. */ name: string; - /** Destination folder ID; defaults to My Drive root or the bound shared-drive root. */ + /** Destination folder ID; defaults to the binding root and otherwise must name a folder created by this app. */ parentId?: string; } @@ -131,11 +130,10 @@ export interface DriveCreationHandle { name: string; } -/** Current outcome of a Drive creation request. */ +/** Current outcome of a Drive creation request. Failed attempts remain pending and can be retried or rejected. */ export type DriveCreationOutcome = - | { status: "pending" } + | { status: "pending"; lastError?: string } | { status: "rejected" } - | { status: "failed"; message: string } | { status: "reverted" } | { status: "created"; kind: DriveCreationKind; entry: DriveEntry }; @@ -144,7 +142,7 @@ export type DriveCreationOutcome = * * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ -export interface GoogleDriveReadSession extends RpcTarget { +export interface GoogleDriveReadSession { /** Return the immutable binding scope with current display metadata. */ getScope(): Promise; @@ -190,6 +188,6 @@ export interface GoogleDriveSession extends GoogleDriveReadSession { createGoogleSheet(options: DriveCreationOptions): Promise; /** Queue creation of a folder. */ createFolder(options: DriveCreationOptions): Promise; - /** Read the current outcome for a previously returned handle. */ + /** Read the current outcome. Old terminal outcomes are retained only within the binding's bounded history. */ getCreationResult(handle: DriveCreationHandle): Promise; } diff --git a/packages/gatekeeper-google/src/drive-types.txt b/packages/gatekeeper-google/src/drive-types.txt index 38e967b8d..8cb37b363 100644 --- a/packages/gatekeeper-google/src/drive-types.txt +++ b/packages/gatekeeper-google/src/drive-types.txt @@ -113,7 +113,7 @@ export type DriveCreationKind = "googleDoc" | "googleSheet" | "folder"; export interface DriveCreationOptions { /** Non-empty name for the new item. */ name: string; - /** Destination folder ID; defaults to My Drive root or the bound shared-drive root. */ + /** Destination folder ID; defaults to the binding root and otherwise must name a folder created by this app. */ parentId?: string; } @@ -127,11 +127,10 @@ export interface DriveCreationHandle { name: string; } -/** Current outcome of a Drive creation request. */ +/** Current outcome of a Drive creation request. Failed attempts remain pending and can be retried or rejected. */ export type DriveCreationOutcome = - | { status: "pending" } + | { status: "pending"; lastError?: string } | { status: "rejected" } - | { status: "failed"; message: string } | { status: "reverted" } | { status: "created"; kind: DriveCreationKind; entry: DriveEntry }; @@ -140,7 +139,7 @@ export type DriveCreationOutcome = * * Methods do not follow shortcut targets, edit Drive, or read non-native file contents. */ -export interface GoogleDriveReadSession extends RpcTarget { +export interface GoogleDriveReadSession { /** Return the immutable binding scope with current display metadata. */ getScope(): Promise; @@ -186,6 +185,6 @@ export interface GoogleDriveSession extends GoogleDriveReadSession { createGoogleSheet(options: DriveCreationOptions): Promise; /** Queue creation of a folder. */ createFolder(options: DriveCreationOptions): Promise; - /** Read the current outcome for a previously returned handle. */ + /** Read the current outcome. Old terminal outcomes are retained only within the binding's bounded history. */ getCreationResult(handle: DriveCreationHandle): Promise; } diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index ef764d9a3..aa22b915a 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -78,10 +78,10 @@ import { import { ObserverCheck, ObserverTracker } from "./observers"; import { CursorPager, Pager } from "./cursor"; import { formatApprovalField, sanitizeApprovalTitle } from "./approval-format"; +import { PendingActionStore } from "./pending-action-store"; import { - applyDriveCreation, assertDriveCreationCapacity, readDriveCreationState, rejectDriveCreation, - revertDriveCreation, submitDriveCreation, validateDriveCreationName, - type DriveCreationStorage, + assertDriveCreationCapacity, DriveCreationCoordinator, readDriveCreationState, + submitDriveCreation, validateDriveCreationName, type DriveCreationStorage, } from "./drive-creation"; const GOOGLE_DOC_TYPES_CODE = [DOCS_READ_TYPES_CODE, DOCS_TYPES_CODE].join("\n"); @@ -1006,43 +1006,6 @@ export class GoogleVerifier extends WorkerEntrypoint } } -class PendingActionStore { - #kv: DurableObjectStorage["kv"]; - - constructor(kv: DurableObjectStorage["kv"]) { - this.#kv = kv; - } - - #actionKey(id: number): string { - return `pending:action:${id}`; - } - - submit(action: Action): number { - let id = this.#kv.get("pending:nextActionId") ?? 1; - this.#kv.put("pending:nextActionId", id + 1); - this.#kv.put(this.#actionKey(id), action); - return id; - } - - get(id: number): Action | undefined { - return this.#kv.get(this.#actionKey(id)); - } - - put(id: number, action: Action): void { - this.#kv.put(this.#actionKey(id), action); - } - - list(): {id: number, action: Action}[] { - return [...this.#kv.list({prefix: "pending:action:"})] - .map(([key, action]) => ({id: Number(key.slice("pending:action:".length)), action})) - .filter(({id}) => Number.isFinite(id)) - .toSorted((a, b) => a.id - b.id); - } - - remove(id: number): void { - this.#kv.delete(this.#actionKey(id)); - } -} // ======================================================================================= // Gmail capability stubs @@ -2972,6 +2935,7 @@ type GoogleDriveGatekeeperImplProps = { export class GoogleDriveGatekeeperImpl extends DurableObject implements Gatekeeper { + #creationCoordinator = new DriveCreationCoordinator(); #tokens = new AccessTokenCache(opts => { let account = this.ctx.exports.UserAccount.get( this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId)); @@ -3039,15 +3003,15 @@ export class GoogleDriveGatekeeperImpl } async applyAction(action: number): Promise { - await applyDriveCreation(this.#creationRuntime(), action); + await this.#creationCoordinator.apply(this.#creationRuntime(), action); } async rejectAction(action: number): Promise { - rejectDriveCreation(this.ctx.storage.kv, action); + await this.#creationCoordinator.reject(this.#creationRuntime(), action); } async revertAction(action: number): Promise { - await revertDriveCreation(this.#creationRuntime(), action); + await this.#creationCoordinator.revert(this.#creationRuntime(), action); } #creationRuntime() { diff --git a/packages/gatekeeper-google/src/pending-action-store.ts b/packages/gatekeeper-google/src/pending-action-store.ts new file mode 100644 index 000000000..64f717339 --- /dev/null +++ b/packages/gatekeeper-google/src/pending-action-store.ts @@ -0,0 +1,50 @@ +/** Synchronous Durable Object KV operations used by pending action storage. */ +export interface PendingActionStorage { + get(key: string): T | undefined; + put(key: string, value: T): void; + delete(key: string): void; + list(options: { prefix: string }): Iterable<[string, T]>; +} + +const ACTION_PREFIX = "pending:action:"; +const NEXT_ACTION_ID_KEY = "pending:nextActionId"; + +/** Shared durable storage for gatekeeper actions awaiting approval callbacks. */ +export class PendingActionStore { + constructor(private storage: PendingActionStorage) {} + + /** Persist an action and return its binding-local sequential ID. */ + submit(action: Action): number { + let id = this.storage.get(NEXT_ACTION_ID_KEY) ?? 1; + this.storage.put(NEXT_ACTION_ID_KEY, id + 1); + this.storage.put(this.#actionKey(id), action); + return id; + } + + /** Return one pending action, if present. */ + get(id: number): Action | undefined { + return this.storage.get(this.#actionKey(id)); + } + + /** Replace one pending action. */ + put(id: number, action: Action): void { + this.storage.put(this.#actionKey(id), action); + } + + /** Return pending actions in ascending action-ID order. */ + list(): { id: number; action: Action }[] { + return [...this.storage.list({ prefix: ACTION_PREFIX })] + .map(([key, action]) => ({ id: Number(key.slice(ACTION_PREFIX.length)), action })) + .filter(({ id }) => Number.isFinite(id)) + .toSorted((a, b) => a.id - b.id); + } + + /** Remove one pending action. */ + remove(id: number): void { + this.storage.delete(this.#actionKey(id)); + } + + #actionKey(id: number): string { + return `${ACTION_PREFIX}${id}`; + } +}