diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index d7b854a20..598eadd3d 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 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 API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. +The agent-facing Drive sessions report the binding scope, list entries, run structured 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. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. -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. 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 use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — 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, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. 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 6e8516ab0..1263bbea9 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -126,16 +126,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-api.test.ts b/packages/gatekeeper-google/__tests__/drive-api.test.ts index 139028b30..930ad27e2 100644 --- a/packages/gatekeeper-google/__tests__/drive-api.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-api.test.ts @@ -39,6 +39,8 @@ const jsonResponse = (body: unknown, status = 200) => const api = (token = "tok") => new DriveApi(async () => token); +const CREATION_REQUEST_ID = "123e4567-e89b-42d3-a456-426614174000"; + function batchResponse(results: { status: number; body?: string; contentId?: string }[]): Response { let boundary = "drive_test_boundary"; let body = results.map((result, index) => [ @@ -54,7 +56,10 @@ function batchResponse(results: { status: number; body?: string; contentId?: str 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", () => { @@ -361,6 +366,214 @@ 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, + appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID }, + 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.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" })]); + + 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, + appProperties: { gadgetsCreationRequestId: CREATION_REQUEST_ID }, + 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("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)) + .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 }); + }); + + 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", () => { 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-creation.test.ts b/packages/gatekeeper-google/__tests__/drive-creation.test.ts new file mode 100644 index 000000000..2ed6c9eff --- /dev/null +++ b/packages/gatekeeper-google/__tests__/drive-creation.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ApprovalQueue } from "@gadgets/workshop-shared/gatekeeper"; +import { + applyDriveCreation, DriveCreationCoordinator, 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"], + appProperties: { gadgetsCreationRequestId: REQUEST_ID }, + 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", + parentAuthority: "appCreated" as const, + 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", authority: "appCreated" }, + 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", parentAuthority: "appCreated", + 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" }); + + 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(); + expect(storage.events.indexOf("put:drive:create:outcome:1")) + .toBeLessThan(storage.events.indexOf("delete:pending:action:1")); + }); + + it("reports a failed attempt as retryable pending state", 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: "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); + 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("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); + 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__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index 901bfaef9..40c48dd65 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -319,6 +319,168 @@ describe("Drive parent folder probe", () => { }); }); +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, trashed: false, + 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", authority: "root", + }); + 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, trashed: false, + capabilities: { canAddChildren: true }, + }), + }); + + await expect(session.resolveCreationParent()).resolves.toEqual({ + id: "drive-1", name: "Team Drive", authority: "root", + }); + expect(getFile).toHaveBeenCalledWith("drive-1"); + expect(prepared).toEqual([["drive-1"]]); + }); + + 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, 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", 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 () => { + 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, trashed: false, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, + 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("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, trashed: false, + appProperties: { gadgetsCreationRequestId: "123e4567-e89b-42d3-a456-426614174000" }, + 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/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index bc5effc28..48a2bbb4e 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -69,14 +69,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 anywhere this Google account can read in Drive, including shared drives it belongs to.", - "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 anywhere this Google account can read in Drive, including shared drives it belongs to.", + "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.", ]); }); @@ -109,8 +109,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", @@ -122,24 +126,39 @@ describe("resourceUrlPatternsToOAuthScopes", () => { ]); }); - it("requires account and file grants to expand beyond metadata-only consent", () => { + it("expands old writable Drive grants without widening exact-file or direct bindings", () => { const drivePatterns = [ GOOGLE_DRIVE_RESOURCE.urlPattern, GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern, ]; - const oldMetadataGrant = [ + 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 = resourcesCoveredByScopes(drivePatterns, oldMetadataGrant); - - expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); - expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); - expect(resourcesCoveredByScopes(drivePatterns, [ + const oldSharedDriveGrant = [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.readonly", - ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + ]; + + expect(resourcesCoveredByScopes(drivePatterns, oldAccountGrant)) + .not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); + expect(resourcesCoveredByScopes(drivePatterns, oldSharedDriveGrant)) + .not.toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + expect(resourcesCoveredByScopes(drivePatterns, 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 d2df2b447..48396c21d 100644 --- a/packages/gatekeeper-google/__tests__/types.test.ts +++ b/packages/gatekeeper-google/__tests__/types.test.ts @@ -64,6 +64,15 @@ describe("embedded agent declarations", () => { expect(compileAgentTypes(types)).toEqual([]); }); + 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); + }); + it("keeps Drive Docs authority read-only", () => { const readTypes = source("docs-read-types.d.ts"); expect(readTypes).toContain("export interface GoogleDocReadSession"); @@ -84,4 +93,43 @@ describe("embedded agent declarations", () => { ); expect(driveTypes).not.toContain("GoogleDocSession>"); }); + + it("splits Drive creation authority from read-only sessions", () => { + const types = [ + source("docs-read-types.txt"), + stripTypeModulePrefix(source("docs-types.txt"), DOCS_TYPES_MODULE_PREFIX), + source("sheets-types.txt"), + stripTypeModulePrefix(source("drive-types.txt"), DRIVE_TYPES_MODULE_PREFIX), + ` + 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/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index dac4a8ced..88c8f7d9a 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,18 +115,42 @@ 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, async fileIds => ({ pendingSets: fileIds, commit() {} }), ); - return { queue, session }; + return { driveApi, queue, session, storage }; } beforeEach(() => { @@ -126,3 +197,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 = 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 f3711d8a3..954e56081 100644 --- a/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-account-configurator-ui.tsx @@ -10,12 +10,12 @@ export default { resourceUrl: () => "https://drive.google.com/drive/my-drive", 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 96ee1fe56..1b3b0384a 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 { `https://drive.google.com/file/d/${encodeURIComponent(values.fileId ?? "")}/view`, render({ values, setValues, ui }) { return
- + - + { 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"); @@ -154,6 +189,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) { @@ -180,13 +243,17 @@ 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 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, name: value.name, @@ -196,6 +263,9 @@ function parseDriveFile(value: unknown): DriveFile { ...(parents ? { parents } : {}), ...(owners ? { owners } : {}), ...(shortcutDetails ? { shortcutDetails } : {}), + ...(trashed === undefined ? {} : { trashed }), + ...(appProperties ? { appProperties } : {}), + ...(capabilities ? { capabilities } : {}), }; } @@ -216,6 +286,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"]; @@ -316,7 +392,7 @@ export class DriveApi { let corpus = options.corpus ?? { kind: "user" }; params.set("corpora", corpus.kind); if (corpus.kind === "drive") params.set("driveId", corpus.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) { @@ -330,13 +406,79 @@ 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 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); + } + throw new Error("Google Drive creation marker lookup exceeded its page limit"); + } + + /** 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. */ @@ -348,7 +490,7 @@ export class DriveApi { if (options.namePrefix?.trim()) { params.set("q", literalClause("name", "contains", options.namePrefix.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) { @@ -399,8 +541,8 @@ export class DriveApi { "Content-Type": `multipart/mixed; boundary=${boundary}`, }, body, - }, getToken, { idempotent: true }); - if (!response.ok) throw await driveError(response); + }, getToken, { idempotent: true, timeoutMs: DRIVE_API_TIMEOUT_MS }); + if (!response.ok) throw await driveError(response, "check file access"); let placed = await parseBatchAccessParts(response, fileIds.length); if (placed.some(part => part.status === 401)) { @@ -419,14 +561,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-creation.ts b/packages/gatekeeper-google/src/drive-creation.ts new file mode 100644 index 000000000..f3aafa4d2 --- /dev/null +++ b/packages/gatekeeper-google/src/drive-creation.ts @@ -0,0 +1,361 @@ +import type { ApprovalQueue } from "@gadgets/workshop-shared/gatekeeper"; +import { formatApprovalField, sanitizeApprovalTitle } from "./approval-format"; +import type { DriveApi, DriveFile } from "./drive-api"; +import { + isDriveFileInScope, validateDriveCreationParent, + 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 OUTCOME_PREFIX = "drive:create:outcome:"; +const NEXT_OUTCOME_SEQUENCE_KEY = "drive:create:nextOutcomeSequence"; +const MAX_PENDING_CREATIONS = 100; +const MAX_TERMINAL_OUTCOMES = 100; + +const MIME_TYPE_BY_KIND: Record = { + 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 type DriveCreationStorage = PendingActionStorage; + +/** 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; + 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; createdFileId?: string } + | { status: "created"; kind: DriveCreationKind; fileId: string } + | { status: "reverted" }; + +/** Current authoritative state before created metadata is freshly observed. */ +export type StoredDriveCreationState = + | { status: "pending"; lastError?: string } + | { status: "rejected" } + | { status: "created"; kind: DriveCreationKind; fileId: string } + | { status: "reverted" }; + +type StoredOutcomeRecord = { + sequence: number; + outcome: StoredDriveCreationOutcome; +}; + +/** Durable action and bounded outcome storage for one Drive binding. */ +export class DriveCreationStore { + #actions: PendingActionStore; + + constructor(private storage: DriveCreationStorage) { + this.#actions = new PendingActionStore(storage); + } + + submit(action: DriveCreationAction): number { + return this.#actions.submit(action); + } + + pendingCount(): number { + return this.#actions.list().length; + } + + getAction(id: number): DriveCreationAction | undefined { + return this.#actions.get(id); + } + + removeAction(id: number): void { + this.#actions.remove(id); + } + + getOutcome(id: number): StoredDriveCreationOutcome | undefined { + return this.storage.get(this.#outcomeKey(id))?.outcome; + } + + 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 { + this.#putOutcome(id, outcome); + this.removeAction(id); + this.#pruneTerminalOutcomes(); + } + + cleanupTerminal(id: number): void { + this.removeAction(id); + this.#pruneTerminalOutcomes(); + } + + #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, + parentAuthority: options.parent.authority, + 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 }; +} + +/** Provider and durable state required by Drive creation callbacks. */ +export 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?.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}`); +} + +/** Apply or idempotently recover one approved creation. */ +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" && 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 | undefined; + try { + let parent = await runtime.api.getFile(action.parentId); + validateDriveCreationParent(runtime.scope, parent, action.parentAuthority); + 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), created?.id ?? knownCreatedFileId); + 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 }); +} + +/** 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?.status === "rejected") { + store.cleanupTerminal(actionId); + return; + } + 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. */ +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; + 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`); + } + 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."); + } + 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); +} + +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/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index ce186321a..53f963e07 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -1,6 +1,9 @@ import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; import { CursorPager, type Pager } from "./cursor"; -import type { DriveApi, DriveCorpus, DriveFile, DriveListFilesOptions } from "./drive-api"; +import { + hasDriveCreationMarker, type DriveApi, type DriveCorpus, type DriveFile, + type DriveListFilesOptions, +} from "./drive-api"; import type { ObserverCheck } from "./observers"; import type { DriveEntry, DriveListOptions, DriveOrder, DriveScope, DriveSearchQuery, @@ -24,6 +27,47 @@ 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; + authority: DriveCreationParentAuthority; +}; + +/** 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, authority: DriveCreationParentAuthority, +): 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.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, authority }; +} + type DriveSessionApi = Pick; type DriveSessionCoreOptions = { @@ -218,6 +262,25 @@ 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 authority: DriveCreationParentAuthority = parentId === undefined ? "root" : "appCreated"; + let resolved = validateDriveCreationParent(this.#scope, parent, authority); + await this.#authorizeIds( + [parent.id], + "Check Google Drive creation destination", + "Check that the requested creation destination belongs to this Drive binding.", + ); + return resolved; + } + async list(options: DriveListOptions = {}): Promise> { if (options.directParentId) await this.#assertParent(options.directParentId); if (this.#scope.kind === "file") return this.#exactFileCursor(); @@ -245,7 +308,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}.`); @@ -260,7 +323,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`, @@ -279,7 +342,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), @@ -312,19 +377,11 @@ export class DriveSessionCore { : { corpus: { kind: "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(); await this.#authorizeIds([parent.id], "Check Google Drive folder", "Check that the requested parent folder belongs to this Drive binding."); if (parent.mimeType !== FOLDER_MIME_TYPE) throw new Error("directParentId must identify a folder"); diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index 5f8c4c92a..f36497649 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -121,12 +121,40 @@ 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 the binding root and otherwise must name a folder created by this app. */ + 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. Failed attempts remain pending and can be retried or rejected. */ +export type DriveCreationOutcome = + | { status: "pending"; lastError?: string } + | { status: "rejected" } + | { 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 { /** Return the immutable binding scope with current display metadata. */ getScope(): Promise; @@ -174,3 +202,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. 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 6ed4f0927..87038f9ea 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -18,7 +18,10 @@ import { driveObserverTracker } from "./drive-observers"; 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, @@ -75,6 +78,12 @@ import { } from "./resources"; import { type ObserverBatchResult, type ObserverCheck, ObserverTracker } from "./observers"; import { CursorPager, Pager } from "./cursor"; +import { formatApprovalField, sanitizeApprovalTitle } from "./approval-format"; +import { PendingActionStore } from "./pending-action-store"; +import { + assertDriveCreationCapacity, DriveCreationCoordinator, readDriveCreationState, + submitDriveCreation, validateDriveCreationName, type DriveCreationStorage, +} from "./drive-creation"; import { DOCS_TYPES_MODULE_PREFIX, DRIVE_TYPES_MODULE_PREFIX, stripTypeModulePrefix, } from "./type-bundle"; @@ -1042,43 +1051,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 @@ -1225,17 +1197,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 = [ @@ -3015,6 +2976,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)); @@ -3032,7 +2994,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 anywhere this Google account can read in Drive, including shared drives it belongs to", suggestedBindingName: "GOOGLE_DRIVE", tsType: "GoogleDriveSession", }; @@ -3042,7 +3004,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_SHARED_DRIVE", tsType: "GoogleDriveSession", }; @@ -3051,9 +3013,9 @@ 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_FILE", - tsType: "GoogleDriveSession", + tsType: "GoogleDriveReadSession", }; } @@ -3072,16 +3034,30 @@ export class GoogleDriveGatekeeperImpl new GoogleDocsApi(getDriveAccessToken), new GoogleSheetsApi(getDriveAccessToken), this.ctx.props.scope, + this.ctx.storage.kv, approvalQueue.dup(), fileIds => this.#observerTracker().prepareObservation(fileIds), ); } - /** 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 this.#creationCoordinator.apply(this.#creationRuntime(), action); + } + + async rejectAction(action: number): Promise { + await this.#creationCoordinator.reject(this.#creationRuntime(), action); + } + + async revertAction(action: number): Promise { + await this.#creationCoordinator.revert(this.#creationRuntime(), action); + } + + #creationRuntime() { + return { + storage: this.ctx.storage.kv, + api: new DriveApi(opts => this.#getAccessToken(opts)), + scope: this.ctx.props.scope, + }; } #observerTracker(): ObserverTracker> { @@ -3154,12 +3130,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>, ) { @@ -3167,6 +3146,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, @@ -3196,6 +3177,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/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}`; + } +} diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index 17084eef7..5de82071a 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -71,7 +71,8 @@ export const BIGQUERY_RESOURCE: SupportedResource = { }; /** - * Files, folders, and read-only native Google Docs and Sheets available to the connected account. + * Files, folders, read-only native Google Docs and Sheets, and blank item creation available to the + * connected account. * * Whole-account, not just My Drive: listings set `includeItemsFromAllDrives`, so a shared drive the * account belongs to is inside this grant. @@ -80,16 +81,16 @@ 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 anywhere this Google account " + - "can read in Drive, including shared drives it belongs to.", + "Find files and folders, read native Google Docs and Sheets, and create blank Docs, Sheets, " + + "and folders anywhere this Google account can read in Drive, including shared drives it belongs to.", 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, }; @@ -171,6 +172,7 @@ 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", ], }, { @@ -182,7 +184,11 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] // shared-drive binding exercises. Narrowing it means dropping both calls: resolving a shared // drive's name through `files.get` on the drive root instead, and giving up drive enumeration in // the configurator. - scopes: ["https://www.googleapis.com/auth/drive.readonly"], + scopes: [ + "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,