diff --git a/docs/observers.md b/docs/observers.md index d6c5534c..54662866 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -511,6 +511,7 @@ its resource types. | **homeassistant** | Instance / Area / Label / Device / Entity | **D** | No-op. Self-hosted personal; the pasted long-lived token is all-or-nothing and HA exposes no per-user/per-entity ACL oracle to check against. | | **github** | Repo / Issue / PR | **B** | Check the observer's GitHub identity has read access to the bound repo (public → always pass; private → collaborator/org-team check). Issues/PRs inherit the repo ACL, so the repo is the atomic unit. | | **google** | Google Doc | **B** | Check the observer's Drive sharing access to the bound document. | +| **google** | Google Drive Folder | **B** | Check the observer's Drive sharing access to the bound folder. The folder is the atomic resource for direct-child metadata and uploads. | | **google** | Google Spreadsheet | **B** | Check the observer's Google Sheets access to the bound spreadsheet. Spreadsheet sharing applies to the whole file, so it is the atomic unit. | | **google** | Google Calendar (selected calendar) | **B** | Require `writer` or `owner` access to the bound calendar, since `reader` access hides private-event details. Future: let the binding owner exclude private events so readers can collaborate. | | **google** | Google Calendar (`allVisible` availability) | **C** | In addition to the selected-calendar check, track foreign calendars whose free/busy data was successfully read and verify each observer can independently query their availability. | diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index 1fea5b76..acf551c5 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -8,7 +8,8 @@ This package provides Google OAuth integration for Gadgets. It serves two purpos which becomes the user's identity. The sign-in grant is transient (discarded right after the email is read). - **Connections:** when a user connects Google (or signs in and later connects it), the scopes for - the selected resources (Gmail, Docs, Sheets, Calendar, or BigQuery — see below) are requested so + the selected resources (Gmail, Docs, Drive folders, Sheets, Calendar, or BigQuery — see below) + are requested so gadgets can access those APIs on the user's behalf. A single Google OAuth client is used for both. Set it up as follows. @@ -29,7 +30,8 @@ If you're running this project locally and want to use Google API integrations, ### Step 2: Enable Required APIs -You'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google Docs, Google Sheets, Google Calendar, and BigQuery. +You'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google +Docs, Google Drive folders, Google Sheets, Google Calendar, and BigQuery. 1. In the left sidebar, go to **APIs & Services** > **Library** (or [click here](https://console.cloud.google.com/apis/library)) 2. Search for "Gmail API" @@ -51,7 +53,9 @@ You'll need to enable the Google APIs that you want to use. Currently supported: 18. Click on **BigQuery API** in the results 19. Click **Enable** -The Google Drive API is used only to search and display document and spreadsheet metadata in the resource pickers. Document reads and edits still go through the Google Docs API, and spreadsheet reads go through the Google Sheets API. +The Google Drive API searches resource pickers and provides folder-scoped file listings and +approval-gated uploads. Document reads and edits still go through the Google Docs API. Spreadsheet +reads and approval-gated writes go through the Google Sheets API. ### Step 3: Configure the OAuth Consent Screen @@ -76,7 +80,11 @@ included). Across all resource types, the gatekeeper can request: - `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending. - `documents` for Google Docs reads and edits. - `drive.metadata.readonly` so the resource pickers can search Google Docs and Sheets by title. -- `spreadsheets.readonly` to read metadata and cell values from selected Google spreadsheets. +- `drive` for a connected Drive-folder resource. The OAuth grant can reach Drive, but the + Gatekeeper capability is fixed to the one folder the employee selects and only exposes listing + direct children and approval-gated uploads. +- `spreadsheets` to read metadata and cell values and to stage bounded writes for selected Google + spreadsheets. Each write remains in the Company OS action queue until a person approves it. - `calendar.calendarlist.readonly` so the resource picker can list calendars. - `calendar.events` to manage selected calendar and check calendar availability. - `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries. @@ -135,7 +143,8 @@ User — see Step 4.) 2. Create or open a gadget. 3. Navigate to the **Connections** tab. 4. Click **+ New Connection**. -5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Calendar, or BigQuery. +5. Choose a Google resource type: Gmail, Google Doc, Google Drive Folder, Google Spreadsheet, + Google Calendar, or BigQuery. 6. If prompted, connect a Google account. 7. You should be redirected to Google's consent screen in a new tab. 8. The consent screen acts extra-scary since this is an "unverified" test app. diff --git a/packages/gatekeeper-google/package.json b/packages/gatekeeper-google/package.json index c73fecf8..0d4e9918 100644 --- a/packages/gatekeeper-google/package.json +++ b/packages/gatekeeper-google/package.json @@ -8,6 +8,7 @@ "build:configurator": "node ../../scripts/build-gatekeeper-configurator.mjs .", "deploy": "pnpm run build:configurator && wrangler deploy", "build": "pnpm run build:configurator && tsc", + "test": "vitest run", "types:check": "pnpm run build:configurator && tsc --noEmit", "clean": "rm -rf dist src/generated" }, @@ -22,6 +23,7 @@ }, "devDependencies": { "typescript": "^5.9.3", + "vitest": "^4.1.10", "wrangler": "^4.115.0" } } diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts new file mode 100644 index 00000000..168d4548 --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts @@ -0,0 +1,13 @@ +export type ConfiguratorOption = { + value: string; + title: string; + subtitle?: string; +}; + +export type DriveFolderConfiguratorValues = { + folderId?: string | null; +}; + +export interface DriveFolderConfiguratorRpc { + listFolders(query: string): Promise; +} diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx new file mode 100644 index 00000000..f1f6e5a0 --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx @@ -0,0 +1,30 @@ +import { Autocomplete, Field, h, Section, type ConfiguratorUISpec } from "@gadgets/configurator-ui"; +import type { + DriveFolderConfiguratorRpc, DriveFolderConfiguratorValues, +} from "./drive-folder-configurator-types"; + +export default { + initial: {}, + + isReady({ values }) { + return typeof values.folderId === "string" && values.folderId.length > 0; + }, + + resourceUrl({ values }) { + return `https://drive.google.com/drive/folders/${encodeURIComponent(values.folderId ?? "")}`; + }, + + render({ values, setValues, ui }) { + return
+ + ui.listFolders(query)} + onChange={folderId => setValues({ folderId })} + /> + +
; + }, +} satisfies ConfiguratorUISpec; diff --git a/packages/gatekeeper-google/src/drive-api.test.ts b/packages/gatekeeper-google/src/drive-api.test.ts new file mode 100644 index 00000000..4b8bb4bf --- /dev/null +++ b/packages/gatekeeper-google/src/drive-api.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GoogleDriveApi } from "./drive-api"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Google Drive folder API", () => { + it("reads and validates the selected folder", async () => { + let requestedUrl = ""; + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + requestedUrl = url; + return Response.json({ + id: "folder-1", + name: "Company OS acceptance", + mimeType: "application/vnd.google-apps.folder", + webViewLink: "https://drive.google.com/drive/folders/folder-1", + trashed: false, + }); + })); + let folder = await new GoogleDriveApi(async () => "token").getFolder("folder-1"); + expect(folder.name).toBe("Company OS acceptance"); + expect(requestedUrl).toContain("supportsAllDrives=true"); + expect(requestedUrl).toContain("fields=id%2Cname%2CmimeType%2CwebViewLink%2Ctrashed"); + }); + + it("lists direct children only", async () => { + let requestedUrl = ""; + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + requestedUrl = url; + return Response.json({ files: [{ + id: "file-1", + name: "invoice.pdf", + mimeType: "application/pdf", + size: "2048", + modifiedTime: "2026-08-08T12:00:00.000Z", + }] }); + })); + let files = await new GoogleDriveApi(async () => "token").listFiles("folder-1", 25); + expect(files[0]).toMatchObject({ name: "invoice.pdf", size: 2048 }); + let url = new URL(requestedUrl); + expect(url.searchParams.get("q")).toBe("'folder-1' in parents and trashed = false"); + expect(url.searchParams.get("pageSize")).toBe("25"); + }); + + it("uploads multipart bytes under the selected folder", async () => { + let request: { url: string; init?: RequestInit } | undefined; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + request = { url, init }; + return Response.json({ + id: "file-1", + name: "invoice.pdf", + mimeType: "application/pdf", + size: "4", + webViewLink: "https://drive.google.com/file/d/file-1/view", + }); + })); + let file = await new GoogleDriveApi(async () => "token").uploadFile("folder-1", { + name: "invoice.pdf", + mimeType: "application/pdf", + bytes: Uint8Array.from([0x25, 0x50, 0x44, 0x46]), + convertToGoogleDoc: false, + }); + + expect(file.webViewLink).toContain("file-1"); + expect(request?.url).toContain("uploadType=multipart"); + expect(request?.url).toContain("supportsAllDrives=true"); + expect(request?.init?.method).toBe("POST"); + expect(new Headers(request?.init?.headers).get("Content-Type")) + .toMatch(/^multipart\/related; boundary=company_os_/); + let body = request?.init?.body as Blob; + let bytes = new Uint8Array(await body.arrayBuffer()); + let text = new TextDecoder().decode(bytes); + expect(text).toContain('"name":"invoice.pdf","parents":["folder-1"]'); + expect([...bytes]).toEqual(expect.arrayContaining([0x25, 0x50, 0x44, 0x46])); + }); + + it("rejects an oversized response before parsing it", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { + headers: { "Content-Length": String(1024 * 1024 + 1) }, + }))); + await expect(new GoogleDriveApi(async () => "token").getFolder("folder-1")) + .rejects.toThrow(/response exceeded/); + }); +}); diff --git a/packages/gatekeeper-google/src/drive-api.ts b/packages/gatekeeper-google/src/drive-api.ts new file mode 100644 index 00000000..f67664a5 --- /dev/null +++ b/packages/gatekeeper-google/src/drive-api.ts @@ -0,0 +1,163 @@ +import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; +import type { DriveFileInfo, DriveFolderInfo } from "./sheets-types"; + +const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3/files"; +const DRIVE_UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3/files"; +const GOOGLE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; +const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; +const MAX_RESPONSE_BYTES = 1024 * 1024; +const REQUEST_TIMEOUT_MS = 60_000; + +type DriveRestFile = { + id?: string; + name?: string; + mimeType?: string; + size?: string; + modifiedTime?: string; + webViewLink?: string; + trashed?: boolean; +}; + +type GoogleErrorResponse = { error?: { message?: string } }; + +async function responseText(response: Response): Promise { + let declared = Number(response.headers.get("Content-Length")); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => {}); + throw new Error(`Google Drive response exceeded the ${MAX_RESPONSE_BYTES}-byte limit.`); + } + if (!response.body) return ""; + let reader = response.body.getReader(); + let chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + let { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => {}); + throw new Error(`Google Drive response exceeded the ${MAX_RESPONSE_BYTES}-byte limit.`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + let bytes = new Uint8Array(length); + let offset = 0; + for (let chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +async function responseJson(response: Response): Promise { + let text = await responseText(response); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + if (!response.ok) throw new Error(`Google Drive request failed [http=${response.status}]`); + throw new Error("Google Drive returned an invalid JSON response."); + } + if (!response.ok) { + let message = (body as GoogleErrorResponse)?.error?.message; + throw new Error( + `Google Drive request failed [http=${response.status}]${message ? `: ${message}` : ""}`, + ); + } + return body as T; +} + +function normalizeFile(file: DriveRestFile): DriveFileInfo { + if (!file.id || !file.name || !file.mimeType) { + throw new Error("Google Drive returned incomplete file metadata."); + } + let parsedSize = file.size === undefined ? undefined : Number(file.size); + return { + id: file.id, + name: file.name, + mimeType: file.mimeType, + ...(Number.isSafeInteger(parsedSize) && parsedSize! >= 0 ? { size: parsedSize } : {}), + ...(file.modifiedTime ? { modifiedTime: new Date(file.modifiedTime) } : {}), + ...(file.webViewLink ? { webViewLink: file.webViewLink } : {}), + }; +} + +export class GoogleDriveApi { + constructor(private getAccessToken: AccessTokenProvider) {} + + async #request(url: URL, init: RequestInit = {}): Promise { + let response = await fetchWithAuthRetry( + url.toString(), init, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, + ); + return responseJson(response); + } + + async getFolder(folderId: string): Promise { + let url = new URL(`${DRIVE_API_BASE}/${encodeURIComponent(folderId)}`); + url.searchParams.set("fields", "id,name,mimeType,webViewLink,trashed"); + url.searchParams.set("supportsAllDrives", "true"); + let folder = await this.#request(url); + if (folder.trashed || folder.mimeType !== GOOGLE_FOLDER_MIME_TYPE || !folder.id || !folder.name) { + throw new Error("The connected Google Drive resource is not an active folder."); + } + return { + id: folder.id, + name: folder.name, + webViewLink: folder.webViewLink ?? `https://drive.google.com/drive/folders/${folder.id}`, + }; + } + + async listFiles(folderId: string, limit: number): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error("Google Drive list limit must be an integer between 1 and 100."); + } + let url = new URL(DRIVE_API_BASE); + url.searchParams.set("pageSize", String(limit)); + url.searchParams.set("orderBy", "modifiedTime desc"); + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("includeItemsFromAllDrives", "true"); + url.searchParams.set( + "fields", + "files(id,name,mimeType,size,modifiedTime,webViewLink,trashed)", + ); + let escapedFolderId = folderId.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + url.searchParams.set("q", `'${escapedFolderId}' in parents and trashed = false`); + let result = await this.#request<{ files?: DriveRestFile[] }>(url); + return (result.files ?? []).map(normalizeFile); + } + + async uploadFile( + folderId: string, + input: { name: string; mimeType: string; bytes: Uint8Array; convertToGoogleDoc: boolean }, + ): Promise { + let boundary = `company_os_${crypto.randomUUID().replaceAll("-", "")}`; + let metadata = { + name: input.name, + parents: [folderId], + ...(input.convertToGoogleDoc ? { mimeType: GOOGLE_DOC_MIME_TYPE } : {}), + }; + let prefix = + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n--${boundary}\r\n` + + `Content-Type: ${input.mimeType}\r\n\r\n`; + let suffix = `\r\n--${boundary}--\r\n`; + let byteBuffer = input.bytes.buffer.slice( + input.bytes.byteOffset, + input.bytes.byteOffset + input.bytes.byteLength, + ) as ArrayBuffer; + let body = new Blob([prefix, byteBuffer, suffix]); + let url = new URL(DRIVE_UPLOAD_BASE); + url.searchParams.set("uploadType", "multipart"); + url.searchParams.set("supportsAllDrives", "true"); + url.searchParams.set("fields", "id,name,mimeType,size,modifiedTime,webViewLink"); + return normalizeFile(await this.#request(url, { + method: "POST", + headers: { "Content-Type": `multipart/related; boundary=${boundary}` }, + body, + })); + } +} diff --git a/packages/gatekeeper-google/src/drive-upload-store.ts b/packages/gatekeeper-google/src/drive-upload-store.ts new file mode 100644 index 00000000..925b59c4 --- /dev/null +++ b/packages/gatekeeper-google/src/drive-upload-store.ts @@ -0,0 +1,190 @@ +import type { DriveFileInfo, DriveUploadStatus } from "./sheets-types"; + +const MAX_PENDING_UPLOADS = 20; +const MAX_RETAINED_UPLOADS = 100; + +type DriveUploadRow = { + id: number; + state: DriveUploadStatus["state"]; + name: string; + mime_type: string; + convert_to_google_doc: number; + bytes: ArrayBuffer | null; + byte_length: number; + submitted_at: number; + file_id: string | null; + file_mime_type: string | null; + file_size: number | null; + file_modified_time: string | null; + file_web_view_link: string | null; + error: string | null; +}; + +export type StoredDriveUpload = { + id: number; + state: DriveUploadStatus["state"]; + name: string; + mimeType: string; + convertToGoogleDoc: boolean; + bytes?: Uint8Array; + byteLength: number; + submittedAt: number; + file?: DriveFileInfo; + error?: string; +}; + +function fromRow(row: DriveUploadRow): StoredDriveUpload { + let file = row.file_id && row.file_mime_type ? { + id: row.file_id, + name: row.name, + mimeType: row.file_mime_type, + ...(row.file_size === null ? {} : { size: row.file_size }), + ...(row.file_modified_time ? { modifiedTime: new Date(row.file_modified_time) } : {}), + ...(row.file_web_view_link ? { webViewLink: row.file_web_view_link } : {}), + } satisfies DriveFileInfo : undefined; + return { + id: row.id, + state: row.state, + name: row.name, + mimeType: row.mime_type, + convertToGoogleDoc: row.convert_to_google_doc === 1, + ...(row.bytes ? { bytes: new Uint8Array(row.bytes) } : {}), + byteLength: row.byte_length, + submittedAt: row.submitted_at, + ...(file ? { file } : {}), + ...(row.error ? { error: row.error } : {}), + }; +} + +/** Durable, facet-local storage for approval-gated Drive upload bytes and outcomes. */ +export class DriveUploadStore { + constructor(private sql: SqlStorage) { + sql.exec(`CREATE TABLE IF NOT EXISTS drive_uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL CHECK (state IN ('pending', 'applying', 'completed', 'rejected', 'failed')), + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + convert_to_google_doc INTEGER NOT NULL CHECK (convert_to_google_doc IN (0, 1)), + bytes BLOB, + byte_length INTEGER NOT NULL, + submitted_at INTEGER NOT NULL, + file_id TEXT, + file_mime_type TEXT, + file_size INTEGER, + file_modified_time TEXT, + file_web_view_link TEXT, + error TEXT + ) STRICT`); + this.#prune(); + } + + get(id: number): StoredDriveUpload | undefined { + let row = this.sql.exec( + "SELECT * FROM drive_uploads WHERE id = ?", id, + ).toArray()[0]; + return row && fromRow(row); + } + + stage(input: { + name: string; + mimeType: string; + convertToGoogleDoc: boolean; + bytes: Uint8Array; + }): StoredDriveUpload { + let { count } = this.sql.exec<{ count: number }>( + "SELECT count(*) AS count FROM drive_uploads WHERE state IN ('pending', 'applying')", + ).one(); + if (count >= MAX_PENDING_UPLOADS) { + throw new Error( + `${MAX_PENDING_UPLOADS} Drive uploads are already pending. Approve or reject them before ` + + "staging another upload.", + ); + } + let submittedAt = Date.now(); + let bytes = input.bytes.buffer.slice( + input.bytes.byteOffset, + input.bytes.byteOffset + input.bytes.byteLength, + ) as ArrayBuffer; + let { id } = this.sql.exec<{ id: number }>( + `INSERT INTO drive_uploads ( + state, name, mime_type, convert_to_google_doc, bytes, byte_length, submitted_at + ) VALUES ('pending', ?, ?, ?, ?, ?, ?) RETURNING id`, + input.name, + input.mimeType, + Number(input.convertToGoogleDoc), + bytes, + input.bytes.byteLength, + submittedAt, + ).one(); + return this.get(id)!; + } + + claim(id: number): StoredDriveUpload { + let upload = this.get(id); + if (!upload) throw new Error(`Google Drive upload ${id} is unknown.`); + if (upload.state === "completed") return upload; + if (upload.state === "rejected") throw new Error(`Google Drive upload ${id} was rejected.`); + if (upload.state === "applying") { + throw new Error( + `Google Drive upload ${id} was interrupted after dispatch and may have completed. ` + + "Check the folder before staging another upload.", + ); + } + if (upload.state === "failed") { + throw new Error(upload.error ?? `Google Drive upload ${id} failed.`); + } + if (!upload.bytes) throw new Error(`Google Drive upload ${id} has no staged file bytes.`); + this.sql.exec("UPDATE drive_uploads SET state = 'applying' WHERE id = ?", id); + return { ...upload, state: "applying" }; + } + + complete(id: number, file: DriveFileInfo): void { + this.sql.exec( + `UPDATE drive_uploads SET + state = 'completed', bytes = NULL, file_id = ?, file_mime_type = ?, file_size = ?, + file_modified_time = ?, file_web_view_link = ?, error = NULL + WHERE id = ?`, + file.id, + file.mimeType, + file.size ?? null, + file.modifiedTime?.toISOString() ?? null, + file.webViewLink ?? null, + id, + ); + this.#prune(); + } + + fail(id: number, error: string): void { + this.sql.exec( + "UPDATE drive_uploads SET state = 'failed', bytes = NULL, error = ? WHERE id = ?", + error, + id, + ); + this.#prune(); + } + + reject(id: number): void { + let upload = this.get(id); + if (!upload || upload.state === "rejected") return; + if (upload.state !== "pending") { + throw new Error(`Google Drive upload ${id} is already ${upload.state}.`); + } + this.sql.exec( + "UPDATE drive_uploads SET state = 'rejected', bytes = NULL WHERE id = ?", + id, + ); + this.#prune(); + } + + discard(id: number): void { + this.sql.exec("DELETE FROM drive_uploads WHERE id = ? AND state = 'pending'", id); + } + + #prune(): void { + this.sql.exec(`DELETE FROM drive_uploads WHERE id IN ( + SELECT id FROM drive_uploads + WHERE state NOT IN ('pending', 'applying') + ORDER BY id DESC LIMIT -1 OFFSET ${MAX_RETAINED_UPLOADS} + )`); + } +} diff --git a/packages/gatekeeper-google/src/google-configurators.ts b/packages/gatekeeper-google/src/google-configurators.ts index 33ea1eba..8b83edb7 100644 --- a/packages/gatekeeper-google/src/google-configurators.ts +++ b/packages/gatekeeper-google/src/google-configurators.ts @@ -5,6 +5,7 @@ import { GoogleCalendarApi } from "./calendar-api"; import { GoogleAccessToken } from "./google-api"; import type { BigQueryConfiguratorRpc } from "./configurator/bigquery-configurator-types"; import type { CalendarConfiguratorRpc } from "./configurator/calendar-configurator-types"; +import type { DriveFolderConfiguratorRpc } from "./configurator/drive-folder-configurator-types"; import type { GmailConfiguratorRpc } from "./configurator/gmail-configurator-types"; import type { GoogleDocConfiguratorRpc } from "./configurator/google-doc-configurator-types"; import type { GoogleSheetsConfiguratorRpc } from "./configurator/google-sheets-configurator-types"; @@ -241,3 +242,18 @@ export class GoogleSheetsConfiguratorUI extends RpcTarget implements GoogleSheet ); } } + +// RPC interface exposed by Gatekeeper to the resource selection/configuration iframe. +@validateRpc() +export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderConfiguratorRpc { + constructor(getToken: () => Promise) { + super(); + googleTokenGetters.set(this, getToken); + } + + async listFolders(query: string): Promise { + return listDriveFiles( + this, query, "application/vnd.google-apps.folder", "Google Drive folders", + ); + } +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index f998a570..84c0d68d 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -8,9 +8,13 @@ import { } from "./types"; import { GoogleDocSession, DocMetadata } from "./docs-types"; import { GoogleDocsApi } from "./docs-api"; -import { GoogleSheetsApi } from "./sheets-api"; +import { GoogleDriveApi } from "./drive-api"; +import { DriveUploadStore } from "./drive-upload-store"; +import { GoogleSheetsApi, validateWrite } from "./sheets-api"; import type { - GoogleSpreadsheetSession, SpreadsheetInfo, SpreadsheetRange, SpreadsheetValueMode, + DriveFileInfo, DriveFolderInfo, DrivePendingUpload, DriveUploadInput, DriveUploadStatus, + GoogleDriveFolderSession, GoogleSpreadsheetSession, SpreadsheetCellValue, SpreadsheetInfo, + SpreadsheetInputMode, SpreadsheetPendingWrite, SpreadsheetRange, SpreadsheetValueMode, } from "./sheets-types"; import { docToMarkdown, markdownToDocRequests, computeReplaceOperations, DocSnapshot } from "./markdown-converter"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; @@ -35,12 +39,14 @@ import SHEETS_TYPES_CODE from "./sheets-types.txt"; import { BigQueryConfiguratorUI, CalendarConfiguratorUI, + DriveFolderConfiguratorUI, GmailConfiguratorUI, GoogleDocConfiguratorUI, GoogleSheetsConfiguratorUI, } from "./google-configurators"; import BIGQUERY_CONFIGURATOR_HTML from "./generated/bigquery-configurator-ui.txt"; import CALENDAR_CONFIGURATOR_HTML from "./generated/calendar-configurator-ui.txt"; +import DRIVE_FOLDER_CONFIGURATOR_HTML from "./generated/drive-folder-configurator-ui.txt"; import GMAIL_CONFIGURATOR_HTML from "./generated/gmail-configurator-ui.txt"; import GOOGLE_DOC_CONFIGURATOR_HTML from "./generated/google-doc-configurator-ui.txt"; import GOOGLE_SHEETS_CONFIGURATOR_HTML from "./generated/google-sheets-configurator-ui.txt"; @@ -239,7 +245,14 @@ const GOOGLE_DOC_RESOURCE: SupportedResource = { const GOOGLE_SHEETS_RESOURCE: SupportedResource = { urlPattern: "https://docs.google.com/spreadsheets/d/:spreadsheetId/*", title: "Google Spreadsheet", - description: "Read values from a spreadsheet you choose.", + description: "Read and approval-gate writes to a spreadsheet you choose.", + grantable: true, +}; + +const GOOGLE_DRIVE_FOLDER_RESOURCE: SupportedResource = { + urlPattern: "https://drive.google.com/drive/folders/:folderId/*", + title: "Google Drive Folder", + description: "List files and approval-gate uploads to a folder you choose.", grantable: true, }; @@ -285,11 +298,20 @@ const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] = [ { resource: GOOGLE_SHEETS_RESOURCE, scopes: [ - "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", // Read-only Drive file metadata, used to power the spreadsheet picker. "https://www.googleapis.com/auth/drive.metadata.readonly", ], }, + { + resource: GOOGLE_DRIVE_FOLDER_RESOURCE, + scopes: [ + // The gatekeeper narrows this account grant to one employee-selected folder. The Drive API + // scope is required because the in-app resource picker is not the Google Picker API and must + // be able to create a child under an existing selected folder. + "https://www.googleapis.com/auth/drive", + ], + }, { resource: GOOGLE_CALENDAR_RESOURCE, scopes: [ @@ -475,7 +497,8 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe async getTypeScriptTypes(): Promise { return [ - TYPES_CODE, DOCS_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, BIGQUERY_TYPES_CODE, + TYPES_CODE, DOCS_TYPES_CODE, SHEETS_TYPES_CODE, CALENDAR_TYPES_CODE, + BIGQUERY_TYPES_CODE, ].join("\n"); } } @@ -846,6 +869,20 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); let obj = this.ctx.exports.UserAccount.get(id); - let granted = new Set(await obj.getGrantedResourceUrlPatterns()); + let granted = new Set(await obj.getGrantedResourceUrlPatterns()); if (resourceUrlPatterns.every(pattern => granted.has(pattern))) { return {}; } @@ -1053,6 +1097,8 @@ export class GatekeeperUserImpl extends WorkerEntrypoint; + hasDriveFolderAccess(folderId: string): Promise; hasSpreadsheetAccess(spreadsheetId: string): Promise; hasCalendarWriterAccess(calendarId: string): Promise; hasCalendarFreeBusyAccess(calendarId: string): Promise; @@ -1116,6 +1163,17 @@ export class GoogleVerifier extends WorkerEntrypoint } } + async hasDriveFolderAccess(folderId: string): Promise { + let api = new GoogleDriveApi(opts => this.#getToken(opts)); + try { + await api.getFolder(folderId); + return true; + } catch (error) { + if (isNoAccessStatus(httpStatusFromError(error))) return false; + throw error; + } + } + async hasSpreadsheetAccess(spreadsheetId: string): Promise { let api = new GoogleSheetsApi(opts => this.#getToken(opts)); try { @@ -2507,10 +2565,259 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { } } +// ======================================================================================= +// Google Drive Folder Gatekeeper +// ======================================================================================= + +const MAX_DRIVE_UPLOAD_BYTES = 10 * 1024 * 1024; +const UPLOAD_DRIVE_FILE_ACTION: ActionKind = { + tag: "uploadDriveFile", + label: "Drive file uploads", +}; + +type GoogleDriveFolderGatekeeperImplProps = { + userObjectId: string; + folderId: string; +}; + +function decodeDriveUpload(input: DriveUploadInput): { + name: string; + mimeType: string; + bytes: Uint8Array; + convertToGoogleDoc: boolean; +} { + let name = input.name?.trim(); + let hasUnsafeControl = [...(name ?? "")].some(character => { + let codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069); + }); + if (!name || new TextEncoder().encode(name).byteLength > 255 || name.includes("/") || + name.includes("\\") || hasUnsafeControl || name === "." || name === "..") { + throw new Error( + "Drive upload names must be 1-255 bytes and cannot contain path separators or controls.", + ); + } + let mimeType = input.mimeType?.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/.test(mimeType)) { + throw new Error("Drive uploads require a valid MIME type."); + } + if (input.convertToGoogleDoc && !mimeType.startsWith("text/")) { + throw new Error("Only textual uploads can be converted to a native Google Doc."); + } + if (typeof input.base64 !== "string" || input.base64.length === 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.base64)) { + throw new Error("Drive upload data must be standard base64."); + } + let decoded: string; + try { + decoded = atob(input.base64); + } catch { + throw new Error("Drive upload data must be valid standard base64."); + } + if (decoded.length === 0 || decoded.length > MAX_DRIVE_UPLOAD_BYTES) { + throw new Error(`Drive uploads must contain between 1 byte and ${MAX_DRIVE_UPLOAD_BYTES} bytes.`); + } + let bytes = Uint8Array.from(decoded, character => character.charCodeAt(0)); + return { name, mimeType, bytes, convertToGoogleDoc: input.convertToGoogleDoc === true }; +} + +@validateRpc() +export class GoogleDriveFolderGatekeeperImpl + extends DurableObject + implements Gatekeeper { + #store: DriveUploadStore | undefined; + #tokens = new AccessTokenCache(opts => { + let account = this.ctx.exports.UserAccount.get( + this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId), + ); + return account.getAccessToken(opts); + }); + + #uploads(): DriveUploadStore { + return this.#store ??= new DriveUploadStore(this.ctx.storage.sql); + } + + async #getAccessToken(opts?: AccessTokenRequest): Promise { + return this.#tokens.get(opts); + } + + async describe(): Promise { + let folder = await new GoogleDriveApi(opts => this.#getAccessToken(opts)) + .getFolder(this.ctx.props.folderId); + return { + url: folder.webViewLink, + title: folder.name, + snippet: `Google Drive folder: ${folder.name}`, + suggestedBindingName: "GOOGLE_DRIVE_FOLDER", + tsType: "GoogleDriveFolderSession", + }; + } + + async getTypeScriptTypes(): Promise { + return SHEETS_TYPES_CODE; + } + + async getAutoApprovableActions(): Promise { + return [UPLOAD_DRIVE_FILE_ACTION]; + } + + async startSession(approvalQueue: RpcStub): Promise { + return new GoogleDriveFolderSessionImpl( + new GoogleDriveApi(opts => this.#getAccessToken(opts)), + this.ctx.props.folderId, + approvalQueue.dup(), + this.#uploads(), + ); + } + + async applyAction(actionId: number): Promise { + let upload = this.#uploads().claim(actionId); + if (upload.state === "completed") return; + try { + let file = await new GoogleDriveApi(opts => this.#getAccessToken(opts)).uploadFile( + this.ctx.props.folderId, + { + name: upload.name, + mimeType: upload.mimeType, + bytes: upload.bytes!, + convertToGoogleDoc: upload.convertToGoogleDoc, + }, + ); + this.#uploads().complete(actionId, file); + } catch (error) { + let message = error instanceof Error ? error.message : String(error); + this.#uploads().fail( + actionId, + `${message} Check the connected folder before staging another upload because the request ` + + "may have reached Google Drive.", + ); + throw error; + } + } + + async rejectAction(actionId: number): Promise { + this.#uploads().reject(actionId); + } + + revertAction(_actionId: number): Promise { + throw new Error("Google Drive uploads do not implement automatic deletion or revert."); + } + + async addObserver(_id: string, user: Fetcher): Promise { + let verifier = user as unknown as Fetcher; + if (!(await verifier.hasDriveFolderAccess(this.ctx.props.folderId))) { + throw new Error( + "This collaborator cannot access the bound Google Drive folder, so they cannot observe " + + "data that this workspace read from it.", + ); + } + } + + async removeObserver(_id: string): Promise {} +} + +@validateRpc() +class GoogleDriveFolderSessionImpl extends RpcTarget implements GoogleDriveFolderSession { + constructor( + private api: GoogleDriveApi, + private folderId: string, + private approvalQueue: RpcStub, + private uploads: DriveUploadStore, + ) { + super(); + } + + [Symbol.dispose](): void { + this.approvalQueue[Symbol.dispose](); + } + + async getFolder(): Promise { + let folder = await this.api.getFolder(this.folderId); + await this.approvalQueue.authorizeObservation({ + title: "Read Google Drive folder metadata", + description: `Read metadata for the connected folder "${folder.name}".`, + }); + return folder; + } + + async listFiles(options?: { limit?: number }): Promise { + let limit = options?.limit ?? 50; + let files = await this.api.listFiles(this.folderId, limit); + await this.approvalQueue.authorizeObservation({ + title: "List Google Drive folder files", + description: `List ${files.length} file(s) directly inside the connected Drive folder.`, + }); + return files; + } + + async uploadFile(input: DriveUploadInput): Promise { + let decoded = decodeDriveUpload(input); + let upload = this.uploads.stage(decoded); + try { + await this.approvalQueue.submitAction(upload.id, { + title: `Upload ${upload.name} to Google Drive`, + description: + `Upload ${upload.byteLength.toLocaleString()} byte(s) to the connected folder` + + `${upload.convertToGoogleDoc ? " and convert the text to a native Google Doc" : ""}.`, + implementsRevert: false, + actionKind: UPLOAD_DRIVE_FILE_ACTION, + autoApprovable: true, + }); + } catch (error) { + this.uploads.discard(upload.id); + throw error; + } + return { actionId: upload.id, name: upload.name, bytes: upload.byteLength }; + } + + async getUpload(actionId: number): Promise { + if (!Number.isInteger(actionId) || actionId < 1) { + throw new Error("Drive upload actionId must be a positive integer."); + } + let upload = this.uploads.get(actionId); + if (!upload) throw new Error(`Google Drive upload ${actionId} is unknown.`); + await this.approvalQueue.authorizeObservation({ + title: "Read Google Drive upload status", + description: `Read state for staged Drive upload ${actionId}.`, + }); + return { + actionId, + state: upload.state, + ...(upload.file ? { file: upload.file } : {}), + ...(upload.error ? { error: upload.error } : {}), + }; + } +} + // ======================================================================================= // Google Sheets Gatekeeper // ======================================================================================= +type GoogleSheetsAction = { + type: "updateRange" | "appendRows"; + spreadsheetId: string; + submittedAt: number; + range: string; + values: SpreadsheetCellValue[][]; + inputMode: SpreadsheetInputMode; +}; + +const EDIT_SPREADSHEET_ACTION: ActionKind = { + tag: "editSpreadsheet", + label: "Spreadsheet edits", +}; + +function spreadsheetActionPreview(values: SpreadsheetCellValue[][]): string { + const json = JSON.stringify(values) + .replaceAll("`", "\\u0060") + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e"); + const limit = 2_000; + return json.length <= limit ? json : `${json.slice(0, limit)}…`; +} + type GoogleSheetsGatekeeperImplProps = { userObjectId: string; spreadsheetId: string; @@ -2537,7 +2844,7 @@ export class GoogleSheetsGatekeeperImpl return { url: `https://docs.google.com/spreadsheets/d/${this.ctx.props.spreadsheetId}/edit`, title: spreadsheet.title, - snippet: `Google Spreadsheet: ${spreadsheet.title} (read-only)`, + snippet: `Google Spreadsheet: ${spreadsheet.title}`, suggestedBindingName: "GOOGLE_SHEET", tsType: "GoogleSpreadsheetSession", }; @@ -2548,25 +2855,50 @@ export class GoogleSheetsGatekeeperImpl } async getAutoApprovableActions(): Promise { - return []; + return [EDIT_SPREADSHEET_ACTION]; } async startSession(approvalQueue: RpcStub): Promise { let api = new GoogleSheetsApi(opts => this.#getAccessToken(opts)); return new GoogleSpreadsheetSessionImpl( - api, this.ctx.props.spreadsheetId, approvalQueue.dup(), + api, + this.ctx.props.spreadsheetId, + approvalQueue.dup(), + new PendingActionStore(this.ctx.storage.kv), ); } - // Read-only — no side-effecting actions. - async applyAction(_action: number): Promise { - throw new Error("Google Sheets is read-only and implements no actions."); + async applyAction(actionId: number): Promise { + let pending = new PendingActionStore(this.ctx.storage.kv); + let action = pending.get(actionId); + if (!action) throw new Error(`Unknown pending Google Sheets action: ${actionId}`); + if (action.spreadsheetId !== this.ctx.props.spreadsheetId) { + pending.remove(actionId); + throw new Error("Pending Google Sheets action targets a different spreadsheet."); + } + let api = new GoogleSheetsApi(opts => this.#getAccessToken(opts)); + if (action.type === "updateRange") { + await api.updateRange( + action.spreadsheetId, action.range, action.values, action.inputMode, + ); + } else { + await api.appendRows( + action.spreadsheetId, action.range, action.values, action.inputMode, + ); + } + pending.remove(actionId); } - async rejectAction(_action: number): Promise { - throw new Error("Google Sheets is read-only and implements no actions."); + + async rejectAction(actionId: number): Promise { + let pending = new PendingActionStore(this.ctx.storage.kv); + if (!pending.get(actionId)) { + throw new Error(`Unknown pending Google Sheets action: ${actionId}`); + } + pending.remove(actionId); } - revertAction(_action: number): Promise { - throw new Error("Google Sheets is read-only and implements no actions."); + + revertAction(_actionId: number): Promise { + throw new Error("Google Sheets writes do not implement revert."); } // Observer tracking — strategy B (ACL check, single unit). Google applies sharing permissions at @@ -2590,16 +2922,19 @@ class GoogleSpreadsheetSessionImpl extends RpcTarget implements GoogleSpreadshee #api: GoogleSheetsApi; #spreadsheetId: string; #approvalQueue: RpcStub; + #pendingActions: PendingActionStore; constructor( api: GoogleSheetsApi, spreadsheetId: string, approvalQueue: RpcStub, + pendingActions: PendingActionStore, ) { super(); this.#api = api; this.#spreadsheetId = spreadsheetId; this.#approvalQueue = approvalQueue; + this.#pendingActions = pendingActions; } [Symbol.dispose](): void { @@ -2652,6 +2987,61 @@ class GoogleSpreadsheetSessionImpl extends RpcTarget implements GoogleSpreadshee }); return result; } + + async updateRange( + range: string, + values: SpreadsheetCellValue[][], + options?: { inputMode?: SpreadsheetInputMode }, + ): Promise { + return this.#stageWrite("updateRange", range, values, options?.inputMode); + } + + async appendRows( + range: string, + values: SpreadsheetCellValue[][], + options?: { inputMode?: SpreadsheetInputMode }, + ): Promise { + return this.#stageWrite("appendRows", range, values, options?.inputMode); + } + + async #stageWrite( + type: GoogleSheetsAction["type"], + range: string, + values: SpreadsheetCellValue[][], + inputMode: SpreadsheetInputMode = "raw", + ): Promise { + let { cellCount } = validateWrite(range, values, type === "updateRange"); + if (inputMode !== "raw" && inputMode !== "userEntered") { + throw new Error(`Unknown Google Sheets input mode: ${String(inputMode)}`); + } + + let action: GoogleSheetsAction = { + type, + spreadsheetId: this.#spreadsheetId, + submittedAt: Date.now(), + range, + values, + inputMode, + }; + let actionId = this.#pendingActions.submit(action); + let operation = type === "updateRange" ? "Replace" : "Append"; + try { + await this.#approvalQueue.submitAction(actionId, { + title: `${operation} Google Sheets values`, + description: + `${operation} ${cellCount.toLocaleString()} cell(s) in range ${range}. ` + + `Values use ${inputMode === "raw" ? "raw" : "user-entered"} interpretation.\n\n` + + `Values preview:\n\n\`${spreadsheetActionPreview(values)}\``, + implementsRevert: false, + actionKind: EDIT_SPREADSHEET_ACTION, + autoApprovable: true, + }); + } catch (error) { + this.#pendingActions.remove(actionId); + throw error; + } + return { actionId, operation: type, range, cellCount }; + } } // ======================================================================================= diff --git a/packages/gatekeeper-google/src/sheets-api.test.ts b/packages/gatekeeper-google/src/sheets-api.test.ts new file mode 100644 index 00000000..b6432fb9 --- /dev/null +++ b/packages/gatekeeper-google/src/sheets-api.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GoogleSheetsApi, validateWrite } from "./sheets-api"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Google Sheets writes", () => { + it("validates bounded rectangular update and append matrices", () => { + expect(validateWrite("'Freshness'!A1:B2", [["URL", "Score"], ["/a", 10]], true)) + .toMatchObject({ cellCount: 4 }); + expect(validateWrite("'Freshness'!A1:B100", [["/a", 10]], false)) + .toMatchObject({ cellCount: 2 }); + expect(() => validateWrite("A1:B2", [[1, 2]], true)).toThrow(/exactly fill/); + expect(() => validateWrite("A:B", [[1, 2]], false)).toThrow(/unbounded/); + expect(() => validateWrite("A1:B2", [[1], [2, 3]], true)).toThrow(/rectangular/); + expect(() => validateWrite("A1:A1", [["x".repeat(101 * 1024)]], true)) + .toThrow(/smaller approval actions/); + }); + + it("uses the official values update contract", async () => { + let requests: { url: string; init?: RequestInit }[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return Response.json({ updatedCells: 4 }); + })); + let api = new GoogleSheetsApi(async () => "token"); + await api.updateRange("sheet-1", "Data!A1:B2", [["a", "b"], [1, 2]], "userEntered"); + + expect(requests).toHaveLength(1); + let request = requests[0]; + expect(request.url).toContain("/sheet-1/values/Data!A1%3AB2"); + expect(request.url).toContain("valueInputOption=USER_ENTERED"); + expect(request.init?.method).toBe("PUT"); + expect(JSON.parse(String(request.init?.body))).toEqual({ + range: "Data!A1:B2", + majorDimension: "ROWS", + values: [["a", "b"], [1, 2]], + }); + }); + + it("uses the official values append contract and inserts rows", async () => { + let request: { url: string; init?: RequestInit } | undefined; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + request = { url, init }; + return Response.json({ updates: { updatedCells: 2 } }); + })); + let api = new GoogleSheetsApi(async () => "token"); + await api.appendRows("sheet-1", "Data!A1:B100", [["/new", 20]]); + + expect(request?.url).toContain("/sheet-1/values/Data!A1%3AB100:append"); + expect(request?.url).toContain("valueInputOption=RAW"); + expect(request?.url).toContain("insertDataOption=INSERT_ROWS"); + expect(request?.init?.method).toBe("POST"); + }); +}); diff --git a/packages/gatekeeper-google/src/sheets-api.ts b/packages/gatekeeper-google/src/sheets-api.ts index 403c585a..f734d7a9 100644 --- a/packages/gatekeeper-google/src/sheets-api.ts +++ b/packages/gatekeeper-google/src/sheets-api.ts @@ -1,11 +1,13 @@ import type { - SpreadsheetCellValue, SpreadsheetInfo, SpreadsheetRange, SpreadsheetValueMode, + SpreadsheetCellValue, SpreadsheetInfo, SpreadsheetInputMode, SpreadsheetRange, + SpreadsheetValueMode, } from "./sheets-types"; import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; const API_BASE = "https://sheets.googleapis.com/v4/spreadsheets"; const MAX_RANGES = 20; const MAX_TOTAL_CELLS = 50_000; +const MAX_WRITE_BYTES = 100 * 1024; const MAX_RANGE_LENGTH = 500; // Bound the encoded JSON before decoding and parsing. const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; @@ -49,7 +51,7 @@ function columnNumber(column: string): number { return result; } -function validateRange(range: string): ValidatedRange { +export function validateRange(range: string): ValidatedRange { if (typeof range !== "string" || range.length === 0 || range.length > MAX_RANGE_LENGTH) { throw new Error(`A1 ranges must contain between 1 and ${MAX_RANGE_LENGTH} characters.`); } @@ -82,6 +84,59 @@ function validateRange(range: string): ValidatedRange { return { range, rows, columns }; } +export function validateWrite( + range: string, + values: SpreadsheetCellValue[][], + requireExactShape: boolean, +): { values: SpreadsheetCellValue[][]; cellCount: number } { + let validatedRange = validateRange(range); + if (!Array.isArray(values) || values.length === 0 || values.length > validatedRange.rows) { + throw new Error( + `A write to ${validatedRange.range} requires between 1 and ${validatedRange.rows} row(s).`, + ); + } + let width = values[0]?.length ?? 0; + if (width === 0 || width > validatedRange.columns) { + throw new Error( + `A write to ${validatedRange.range} requires between 1 and ` + + `${validatedRange.columns} column(s).`, + ); + } + if (values.some(row => !Array.isArray(row) || row.length !== width)) { + throw new Error("Google Sheets writes require a rectangular value matrix."); + } + if (requireExactShape && + (values.length !== validatedRange.rows || width !== validatedRange.columns)) { + throw new Error( + `updateRange values must exactly fill ${validatedRange.range} ` + + `(${validatedRange.rows} row(s) by ${validatedRange.columns} column(s)).`, + ); + } + let cellCount = values.length * width; + if (cellCount > MAX_TOTAL_CELLS) { + throw new Error(`A write may contain at most ${MAX_TOTAL_CELLS.toLocaleString()} cells.`); + } + for (let row of values) { + for (let value of row) normalizeCell(value); + } + let encodedBytes = new TextEncoder().encode(JSON.stringify(values)).byteLength; + if (encodedBytes > MAX_WRITE_BYTES) { + throw new Error( + `A staged write may contain at most ${MAX_WRITE_BYTES.toLocaleString()} encoded bytes. ` + + "Split this change into smaller approval actions.", + ); + } + return { values, cellCount }; +} + +function valueInputOption(mode: SpreadsheetInputMode | undefined): string { + switch (mode ?? "raw") { + case "raw": return "RAW"; + case "userEntered": return "USER_ENTERED"; + default: throw new Error(`Unknown Google Sheets input mode: ${String(mode)}`); + } +} + function validateRanges(ranges: string[]): ValidatedRange[] { if (!Array.isArray(ranges) || ranges.length === 0 || ranges.length > MAX_RANGES) { throw new Error(`readRanges requires between 1 and ${MAX_RANGES} ranges.`); @@ -164,9 +219,9 @@ async function readResponseText(response: Response, maxBytes: number): Promise(url: URL): Promise { + async #request(url: URL, init: RequestInit = {}): Promise { let response = await fetchWithAuthRetry( - url.toString(), {}, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, + url.toString(), init, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS }, ); let text: string; @@ -245,4 +300,41 @@ export class GoogleSheetsApi { let returned = result.valueRanges ?? []; return validated.map((range, index) => normalizeRange(returned[index] ?? {}, range)); } + + async updateRange( + spreadsheetId: string, + range: string, + values: SpreadsheetCellValue[][], + inputMode?: SpreadsheetInputMode, + ): Promise { + validateWrite(range, values, true); + let url = new URL( + `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}`, + ); + url.searchParams.set("valueInputOption", valueInputOption(inputMode)); + await this.#request(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ range, majorDimension: "ROWS", values }), + }); + } + + async appendRows( + spreadsheetId: string, + range: string, + values: SpreadsheetCellValue[][], + inputMode?: SpreadsheetInputMode, + ): Promise { + validateWrite(range, values, false); + let url = new URL( + `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:append`, + ); + url.searchParams.set("valueInputOption", valueInputOption(inputMode)); + url.searchParams.set("insertDataOption", "INSERT_ROWS"); + await this.#request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ range, majorDimension: "ROWS", values }), + }); + } } diff --git a/packages/gatekeeper-google/src/sheets-types.d.ts b/packages/gatekeeper-google/src/sheets-types.d.ts index 7e55d16b..eb5b2b37 100644 --- a/packages/gatekeeper-google/src/sheets-types.d.ts +++ b/packages/gatekeeper-google/src/sheets-types.d.ts @@ -48,7 +48,26 @@ export type SpreadsheetRange = { values: SpreadsheetCellValue[][]; }; -/** Read-only access to one selected Google spreadsheet. */ +/** How values written to cells should be interpreted. */ +export type SpreadsheetInputMode = + /** Store values exactly as supplied. */ + | "raw" + /** Parse values as if an employee entered them in Google Sheets. */ + | "userEntered"; + +/** A bounded write that is waiting in the Company OS action queue. */ +export type SpreadsheetPendingWrite = { + /** Gatekeeper-local action identifier used by the action queue. */ + actionId: number; + /** Canonical operation staged for approval. */ + operation: "updateRange" | "appendRows"; + /** A1 range affected by the write. */ + range: string; + /** Number of cells supplied by the caller. */ + cellCount: number; +}; + +/** Read and approval-gated write access to one selected Google spreadsheet. */ export interface GoogleSpreadsheetSession { /** Return spreadsheet metadata and its worksheet list. */ getSpreadsheet(): Promise; @@ -71,4 +90,101 @@ export interface GoogleSpreadsheetSession { ranges: string[], options?: { valueMode?: SpreadsheetValueMode }, ): Promise; + + /** + * Stage replacement values for one bounded A1 range. The write stays pending until a person + * approves its Company OS action card. The value matrix must fit the declared range exactly and + * remain within the 100 KiB staged-action limit. + */ + updateRange( + range: string, + values: SpreadsheetCellValue[][], + options?: { inputMode?: SpreadsheetInputMode }, + ): Promise; + + /** + * Stage rows to append after the current table in a bounded A1 range. The write stays pending + * until a person approves its Company OS action card. Split input larger than 100 KiB into + * separate approval actions. + */ + appendRows( + range: string, + values: SpreadsheetCellValue[][], + options?: { inputMode?: SpreadsheetInputMode }, + ): Promise; +} + +/** Metadata about one file in the connected Google Drive folder. */ +export type DriveFileInfo = { + /** Stable Google Drive file ID. */ + id: string; + /** File name shown in Google Drive. */ + name: string; + /** Google Drive MIME type. */ + mimeType: string; + /** File size in bytes when Google supplies it. Native Google files do not have a byte size. */ + size?: number; + /** Last modification time. */ + modifiedTime?: Date; + /** URL that opens the file in Google Drive. */ + webViewLink?: string; +}; + +/** Metadata about the connected Google Drive folder. */ +export type DriveFolderInfo = { + /** Stable Google Drive folder ID. */ + id: string; + /** Folder name shown in Google Drive. */ + name: string; + /** URL that opens the folder in Google Drive. */ + webViewLink: string; +}; + +/** File data staged for upload after Company OS approval. */ +export type DriveUploadInput = { + /** File name, including a useful extension. */ + name: string; + /** MIME type of the supplied bytes. */ + mimeType: string; + /** Standard base64-encoded file bytes. Maximum decoded size: 10 MiB. */ + base64: string; + /** Convert textual input to a native Google Doc instead of keeping the source MIME type. */ + convertToGoogleDoc?: boolean; +}; + +/** An upload that is waiting in the Company OS action queue. */ +export type DrivePendingUpload = { + /** Gatekeeper-local action identifier used by the action queue. */ + actionId: number; + /** File name shown on the action card. */ + name: string; + /** Decoded upload size in bytes. */ + bytes: number; +}; + +/** Durable state for one staged Drive upload. */ +export type DriveUploadStatus = { + /** Gatekeeper-local action identifier. */ + actionId: number; + /** Current upload state. */ + state: "pending" | "applying" | "completed" | "rejected" | "failed"; + /** Uploaded file metadata after successful approval and completion. */ + file?: DriveFileInfo; + /** Failure detail when the outcome is known to have failed. */ + error?: string; +}; + +/** Read and approval-gated upload access to one employee-selected Google Drive folder. */ +export interface GoogleDriveFolderSession { + /** Return metadata for the connected folder. */ + getFolder(): Promise; + + /** List the most recently modified files directly inside the connected folder. */ + listFiles(options?: { limit?: number }): Promise; + + /** Stage one file upload. The upload cannot run until a person approves its action card. */ + uploadFile(input: DriveUploadInput): Promise; + + /** Read durable pending, completed, rejected, or failed state for a staged upload. */ + getUpload(actionId: number): Promise; } diff --git a/packages/gatekeeper-google/vitest.config.ts b/packages/gatekeeper-google/vitest.config.ts new file mode 100644 index 00000000..3b81ccfd --- /dev/null +++ b/packages/gatekeeper-google/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + exclude: [".wrangler/**", "dist/**", "node_modules/**"], + }, +}); diff --git a/packages/gatekeeper-google/worker-configuration.d.ts b/packages/gatekeeper-google/worker-configuration.d.ts index 25c84fd2..9ebb29d4 100644 --- a/packages/gatekeeper-google/worker-configuration.d.ts +++ b/packages/gatekeeper-google/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b6042550eba4fdc3796bf337f6a4145b) +// Generated by Wrangler by running `wrangler types` (hash: 71844b45c3e9aa43dd868bf655e2be80) // Runtime types generated with workerd@1.20260731.1 2026-02-02 allow_irrevocable_stub_storage,nodejs_als interface __BaseEnv_Env { CLIENT_ID: string; @@ -8,7 +8,7 @@ interface __BaseEnv_Env { declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/google"); - durableNamespaces: "UserAccount" | "GmailGatekeeperImpl" | "BigQueryGatekeeperImpl" | "GoogleCalendarGatekeeperImpl" | "GoogleSheetsGatekeeperImpl"; + durableNamespaces: "UserAccount" | "GmailGatekeeperImpl" | "BigQueryGatekeeperImpl" | "GoogleCalendarGatekeeperImpl" | "GoogleSheetsGatekeeperImpl" | "GoogleDriveFolderGatekeeperImpl"; } interface Env extends __BaseEnv_Env {} } diff --git a/packages/gatekeeper-google/wrangler.jsonc b/packages/gatekeeper-google/wrangler.jsonc index 662b3d0c..a8bd04da 100644 --- a/packages/gatekeeper-google/wrangler.jsonc +++ b/packages/gatekeeper-google/wrangler.jsonc @@ -26,6 +26,10 @@ { "tag": "v3", "new_sqlite_classes": [ "GoogleSheetsGatekeeperImpl" ] + }, + { + "tag": "v4", + "new_sqlite_classes": [ "GoogleDriveFolderGatekeeperImpl" ] } ], "observability": { diff --git a/packages/gatekeeper-mcp/__tests__/deployment.test.ts b/packages/gatekeeper-mcp/__tests__/deployment.test.ts new file mode 100644 index 00000000..77cc6732 --- /dev/null +++ b/packages/gatekeeper-mcp/__tests__/deployment.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { readFixedMcpService } from "../src/deployment.js"; + +function env(values: Partial): Env { + return values as Env; +} + +describe("readFixedMcpService", () => { + const service = { fetch: async () => new Response("ok") } as unknown as Fetcher; + + it("creates a fixed auto-provisioned resource only when a service binding is present", () => { + const config = readFixedMcpService(env({ + MCP_SERVER_URL: "https://sop-agents.internal/mcp", + MCP_SERVER_NAME: "SOP Agents", + MCP_SERVER_ID: "sop-agents", + MCP_SERVICE: service, + })); + + expect(config).toMatchObject({ + endpoint: "https://sop-agents.internal/mcp", + name: "SOP Agents", + resource: { urlPattern: "https://sop-agents.internal/*", title: "SOP Agents" }, + server: { + endpoint: "https://sop-agents.internal/mcp", + serverId: "sop-agents", + serverName: "SOP Agents", + provenance: "deployment", + auth: "none", + }, + }); + expect(readFixedMcpService(env({ + MCP_SERVER_URL: "https://sop-agents.internal/mcp", + }))).toBeNull(); + }); + + it.each([ + "not a url", + "http://sop-agents.internal/mcp", + "https://user:password@sop-agents.internal/mcp", + "https://sop-agents.internal/mcp#fragment", + ])("refuses an unsafe resource identifier: %s", (url) => { + expect(readFixedMcpService(env({ MCP_SERVER_URL: url, MCP_SERVICE: service }))).toBeNull(); + }); +}); diff --git a/packages/gatekeeper-mcp/src/deployment.ts b/packages/gatekeeper-mcp/src/deployment.ts new file mode 100644 index 00000000..473e3bc7 --- /dev/null +++ b/packages/gatekeeper-mcp/src/deployment.ts @@ -0,0 +1,52 @@ +import type { SupportedResource } from "@gadgets/workshop-shared/gatekeeper"; +import type { ConnectedServer } from "@gadgets/mcp-shared/account"; + +import { serverIdFromEndpoint } from "./server-id.js"; + +/** A deployment-owned MCP server reached only through a Workers service binding. */ +export type FixedMcpService = { + endpoint: string; + name: string; + server: ConnectedServer; + resource: SupportedResource; +}; + +/** + * Reads the optional fixed-service configuration. + * + * The URL is a stable MCP resource identifier and supplies the request path to the bound Worker. + * It is never fetched through the public Internet when MCP_SERVICE is present. + */ +export function readFixedMcpService(env: Env): FixedMcpService | null { + const raw = env.MCP_SERVER_URL?.trim(); + if (!raw || !env.MCP_SERVICE) return null; + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.hash) return null; + + const endpoint = url.toString(); + const name = env.MCP_SERVER_NAME?.trim() || url.hostname; + const serverId = env.MCP_SERVER_ID?.trim() || serverIdFromEndpoint(endpoint); + return { + endpoint, + name, + server: { + endpoint, + serverId, + serverName: name, + provenance: "deployment", + auth: "none", + }, + resource: { + urlPattern: `${url.origin}/*`, + title: name, + description: + "Company capabilities provided through an internal Workers service binding. Writes need approval.", + }, + }; +} diff --git a/packages/gatekeeper-mcp/src/mcp.ts b/packages/gatekeeper-mcp/src/mcp.ts index 59c09e78..07a8804b 100644 --- a/packages/gatekeeper-mcp/src/mcp.ts +++ b/packages/gatekeeper-mcp/src/mcp.ts @@ -64,6 +64,7 @@ import { import { connectFormHtml } from "./connect-form.js"; import { serverIdFromEndpoint } from "./server-id.js"; import { mcpResourceFor, mcpResources } from "./resources.js"; +import { readFixedMcpService } from "./deployment.js"; import type { ConfiguratorUIOption } from "@gadgets/configurator-ui"; import { MCP_BASE_TYPES } from "@gadgets/mcp-shared/base-types"; import MCP_LOGO_SVG from "./mcp-logo.svg"; @@ -168,15 +169,21 @@ async function continueConnect( @validateRpc() export class GatekeeperVendor extends WorkerEntrypoint implements GatekeeperVendorIface { async describe(): Promise { + const fixed = readFixedMcpService(this.env); return { - displayName: "MCP Server", + displayName: fixed?.name ?? "MCP Server", url: "https://modelcontextprotocol.io", logo: MCP_AVATAR, color: "#1a1d21", - tagline: "Connect any Model Context Protocol server", - description: - "Connect a Model Context Protocol server and use its tools from a Gadget. Reads happen " + - "straight away. Anything that writes waits for your approval.", + tagline: fixed + ? "Company capabilities, connected automatically" + : "Connect any Model Context Protocol server", + description: fixed + ? `${fixed.name} is available through an internal Workers service binding. Reads happen ` + + "straight away. Anything that writes waits for your approval." + : "Connect a Model Context Protocol server and use its tools from a Gadget. Reads happen " + + "straight away. Anything that writes waits for your approval.", + ...(fixed ? { autoProvisionsAccount: true, providesAuth: false } : {}), }; } @@ -184,6 +191,9 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe callback: Fetcher, _options?: GatekeeperConnectOptions, ): Promise<{ url: string }> { + if (readFixedMcpService(this.env)) { + throw new Error("This MCP service is connected automatically and has no authorization flow."); + } const accountId = this.ctx.exports.McpAccount.newUniqueId(); const initiationNonce = generateNonce(); await this.ctx.exports.McpAccount.get(accountId).setCallback(callback, initiationNonce); @@ -191,6 +201,8 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe } async getSupportedResources(): Promise { + const fixed = readFixedMcpService(this.env); + if (fixed) return [fixed.resource]; return mcpResources(fetchOptions(this.env).allowInsecure === true); } @@ -199,6 +211,17 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe // generated per resource in `McpGatekeeperImpl.getTypeScriptTypes()`. return MCP_BASE_TYPES; } + + @skipRpcValidation() + async createAccount(): Promise> { + const fixed = readFixedMcpService(this.env); + if (!fixed) throw new Error("This MCP connector requires an explicit account connection."); + const accountId = this.ctx.exports.McpAccount.newUniqueId(); + const account = this.ctx.exports.McpAccount.get(accountId); + await account.provision(fixed.server); + const props: McpGatekeeperUserProps = { accountObjectId: accountId.toString() }; + return this.ctx.exports.GatekeeperUserImpl({ props }) as unknown as Fetcher; + } } // --------------------------------------------------------------------------- @@ -229,6 +252,10 @@ export class McpAccount extends McpAccountBase { async isAwaitingSelection(initiationNonce: string): Promise { return this.awaitingSelection(initiationNonce); } + + async provision(server: ConnectedServer): Promise { + await this.provisionService(server); + } } // --------------------------------------------------------------------------- @@ -249,6 +276,8 @@ export class GatekeeperUserImpl } async getSupportedResources(): Promise { + const fixed = readFixedMcpService(this.env); + if (fixed) return [fixed.resource]; return mcpResources(fetchOptions(this.env).allowInsecure === true); } @@ -258,6 +287,13 @@ export class GatekeeperUserImpl }> { const server = await this.#account().getServer(); + const fixed = readFixedMcpService(this.env); + if (fixed && !sameEndpoint(server.endpoint, fixed.endpoint)) { + throw new Error( + `This connection is for ${server.endpoint}, but the deployment now provides ` + + `${fixed.endpoint}. Replace the connection.`); + } + // The account is bound to one endpoint, so a resource URL naming anything else is not this // account's to grant, and the protocol-specific resource pattern matches any URL so this is the whole // test. Compared in full rather than by origin: one host can front `/mcp` and `/mcp-v2` as @@ -302,6 +338,13 @@ export class GatekeeperUserImpl }; } + async reconnect(): Promise<{ url: string }> { + if (readFixedMcpService(this.env)) { + throw new Error("This MCP service binding has no credentials to reconnect."); + } + return super.reconnect(); + } + @skipRpcValidation() async getVerifier(): Promise> { return this.ctx.exports.McpVerifier({}); diff --git a/packages/gatekeeper-mcp/worker-configuration.d.ts b/packages/gatekeeper-mcp/worker-configuration.d.ts index c7edab86..ed40e36b 100644 --- a/packages/gatekeeper-mcp/worker-configuration.d.ts +++ b/packages/gatekeeper-mcp/worker-configuration.d.ts @@ -4,6 +4,10 @@ interface __BaseEnv_Env { BASE_URL?: string; MCP_ALLOW_INSECURE?: string; MCP_CLIENT_NAME?: string; + MCP_SERVER_URL?: string; + MCP_SERVER_NAME?: string; + MCP_SERVER_ID?: string; + MCP_SERVICE?: Fetcher; } declare namespace Cloudflare { interface GlobalProps { diff --git a/packages/mcp-shared/__tests__/connection-service.test.ts b/packages/mcp-shared/__tests__/connection-service.test.ts new file mode 100644 index 00000000..313abe16 --- /dev/null +++ b/packages/mcp-shared/__tests__/connection-service.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +import { connectionFetch } from "../src/connection.js"; + +describe("connectionFetch", () => { + it("forwards the MCP request through the configured Workers service binding", async () => { + const fetch = vi.fn(async (request: Request) => { + expect(request.url).toBe("https://sop-agents.internal/mcp"); + expect(request.method).toBe("POST"); + expect(await request.text()).toBe('{"jsonrpc":"2.0"}'); + return new Response("ok"); + }); + const transport = connectionFetch({ MCP_SERVICE: { fetch } as unknown as Fetcher }); + + const response = await transport!("https://sop-agents.internal/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"jsonrpc":"2.0"}', + }); + + expect(response.status).toBe(200); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("uses guarded HTTP when no service binding exists", () => { + expect(connectionFetch({})).toBeUndefined(); + }); +}); diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index ab3612f0..e80d46c8 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -24,7 +24,12 @@ import { } from "@modelcontextprotocol/client"; import { McpAuthRequiredError, McpClient, type McpServerInfo } from "./client.js"; -import { clientName, type ConnectionEnv, type McpConnection } from "./connection.js"; +import { + clientName, + connectionFetch, + type ConnectionEnv, + type McpConnection, +} from "./connection.js"; import { ACCESS_TOKEN_SAFETY_MS, CONNECT_TIMEOUT_MS, @@ -371,7 +376,13 @@ export abstract class McpAccountBase server: ConnectedServer, accessToken: string | null, generation: number, ): Promise { const token = accessToken ?? (server.auth === "token" ? this.staticToken(server) : null); - const client = new McpClient(server.endpoint, async () => token, null, this.fetchOptions()); + const client = new McpClient( + server.endpoint, + async () => token, + null, + this.fetchOptions(), + connectionFetch(this.env), + ); const info = await client.initialize(clientName(this.env)); // A newer attempt may have started while initialize was in flight. Its session belongs to that // attempt, not this response, so only the captured generation may populate the cache. @@ -381,6 +392,47 @@ export abstract class McpAccountBase return info; } + /** + * Provisions an account for a deployment-owned Worker service binding. + * + * The service binding itself is the permission. There is no browser connect flow, bearer token, + * or OAuth callback. The MCP initialize call still runs before the account is returned so a bad + * target cannot become a visible but unusable connection. + */ + protected async provisionService(server: ConnectedServer): Promise { + if (!this.env.MCP_SERVICE) { + throw new Error("This MCP deployment has no service binding configured."); + } + const existing = this.server(); + if (existing) { + if (!sameEndpoint(existing.endpoint, server.endpoint)) { + throw new Error("This MCP service account is already provisioned for another endpoint."); + } + return; + } + + const generation = this.advanceConnectionGeneration(); + const connected = { ...server, auth: "none" as const }; + this.ctx.storage.kv.put("server", connected); + try { + await this.probe(connected, null, generation); + if (!this.isCurrentConnection(connected, generation)) { + throw new Error("This MCP service connection changed while it was being provisioned."); + } + this.ctx.storage.kv.put("connected", true); + this.log().info("service binding provisioned", { + event: "connect.service.provisioned", + serverId: connected.serverId, + serverHost: hostOf(connected.endpoint), + provenance: connected.provenance, + }); + } catch (error) { + this.ctx.storage.kv.delete("server"); + this.ctx.storage.kv.delete("mcpSessionId"); + throw error; + } + } + private oauthProvider( server: ConnectedServer, generation: number, diff --git a/packages/mcp-shared/src/client.ts b/packages/mcp-shared/src/client.ts index e0b16aba..df0c97b0 100644 --- a/packages/mcp-shared/src/client.ts +++ b/packages/mcp-shared/src/client.ts @@ -287,12 +287,18 @@ function clampTool(tool: McpWireTool): McpTool { // Bearer token supplier; returns null for servers that need no authorization. export type AuthorizationProvider = () => Promise; +// Request transport used by the client. Remote MCP connectors use guardedFetch; a deployment may +// instead supply a Workers service binding transport so an internal server never needs a public +// URL or an authentication credential. +export type McpFetch = (url: string, init: RequestInit) => Promise; + // A stateless-per-instance MCP client. Construct one per operation; the only state worth keeping // across calls is the transport session id, which the caller owns (see `sessionId`). export class McpClient { #endpoint: string; #getAuthorization: AuthorizationProvider; #fetchOptions: FetchOptions; + #fetch: McpFetch; #requestId = 0; // Transport session id, assigned by the server during `initialize`. Persist and pass it back. @@ -303,11 +309,13 @@ export class McpClient { getAuthorization: AuthorizationProvider, sessionId?: string | null, fetchOptions: FetchOptions = {}, + fetcher?: McpFetch, ) { this.#endpoint = endpoint; this.#getAuthorization = getAuthorization; this.sessionId = sessionId ?? null; this.#fetchOptions = fetchOptions; + this.#fetch = fetcher ?? ((url, init) => guardedFetch(url, init, this.#fetchOptions)); } // The credential most recently sent, kept only so it can be recognised if it comes back. See @@ -328,11 +336,11 @@ export class McpClient { } async #post(body: unknown): Promise { - const response = await guardedFetch(this.#endpoint, { + const response = await this.#fetch(this.#endpoint, { method: "POST", headers: await this.#headers(), body: JSON.stringify(body), - }, this.#fetchOptions); + }); // Only 401 means the credentials are the problem. A 403 is an authenticated caller refused this // particular tool; treating it as an auth failure would mark the account expired and prompt a diff --git a/packages/mcp-shared/src/connection.ts b/packages/mcp-shared/src/connection.ts index fed4db59..74627fe2 100644 --- a/packages/mcp-shared/src/connection.ts +++ b/packages/mcp-shared/src/connection.ts @@ -7,7 +7,13 @@ // landed. A write is left to fail as outcome-unknown; its approved action is closed, and a person // must deliberately stage a new one after checking whether the first took effect. -import { McpAuthRequiredError, McpClient, McpSessionExpiredError, type ToolCatalog } +import { + McpAuthRequiredError, + McpClient, + McpSessionExpiredError, + type McpFetch, + type ToolCatalog, +} from "./client.js"; import { fetchOptions, type InsecureEnv } from "./fetch.js"; import { MAX_TOOLS_PER_SERVER } from "./tools.js"; @@ -16,6 +22,9 @@ import { MAX_TOOLS_PER_SERVER } from "./tools.js"; export type ConnectionEnv = InsecureEnv & { // Name this deployment reports to MCP servers during `initialize`. MCP_CLIENT_NAME?: string; + // Optional internal transport. When present, MCP requests go to this Worker service binding + // instead of the public Internet. The URL remains a stable resource identifier and request path. + MCP_SERVICE?: Fetcher; }; export type WithClientOptions = { @@ -52,6 +61,12 @@ export function clientName(env: ConnectionEnv): string { return env.MCP_CLIENT_NAME ?? "Gadgets"; } +/** Selects the standard guarded HTTP transport or a credential-free Workers service binding. */ +export function connectionFetch(env: ConnectionEnv): McpFetch | undefined { + if (!env.MCP_SERVICE) return undefined; + return (url, init) => env.MCP_SERVICE!.fetch(new Request(url, init)); +} + // Runs `fn` against an initialized client for `endpoint`, using the account's credentials. export async function withClient( env: ConnectionEnv, @@ -64,7 +79,12 @@ export async function withClient( // valid here stays valid for the handful of requests a single `withClient` makes. const { authorization, sessionId, generation } = await account.getConnection(endpoint); const client = new McpClient( - endpoint, async () => authorization, sessionId, fetchOptions(env)); + endpoint, + async () => authorization, + sessionId, + fetchOptions(env), + connectionFetch(env), + ); const run = async (): Promise => { const result = await fn(client); diff --git a/packages/workshop-backend/__tests__/access.test.ts b/packages/workshop-backend/__tests__/access.test.ts index f3e231ac..ca025acd 100644 --- a/packages/workshop-backend/__tests__/access.test.ts +++ b/packages/workshop-backend/__tests__/access.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { accessRateLimitKey, verifyCfAccessJwt } from "../src/access.js"; +import { accessRateLimitKey, verifyCfAccessJwt, verifyMachineAccess } from "../src/access.js"; const joseMocks = vi.hoisted(() => ({ createRemoteJWKSet: vi.fn(() => vi.fn()), @@ -74,3 +74,47 @@ describe("accessRateLimitKey", () => { expect(emailKey).not.toContain("person@example.com"); }); }); + +describe("verifyMachineAccess", () => { + const machineEnv = { + COMPANY_OS_MACHINE_ADMIN_EMAIL: "Admin@Example.com ", + COMPANY_OS_MACHINE_TOKEN: "deployment-secret", + }; + + it("returns the normalized configured administrator for the exact bearer token", async () => { + const request = new Request("https://workshop.example/api", { + headers: { authorization: "Bearer deployment-secret" }, + }); + + await expect(verifyMachineAccess(request, machineEnv)).resolves.toBe("admin@example.com"); + }); + + it("rejects missing, malformed, and incorrect bearer tokens", async () => { + await expect(verifyMachineAccess( + new Request("https://workshop.example/api"), machineEnv, + )).resolves.toBeNull(); + await expect(verifyMachineAccess( + new Request("https://workshop.example/api", { + headers: { authorization: "Basic deployment-secret" }, + }), machineEnv, + )).resolves.toBeNull(); + await expect(verifyMachineAccess( + new Request("https://workshop.example/api", { + headers: { authorization: "Bearer wrong-secret" }, + }), machineEnv, + )).resolves.toBeNull(); + }); + + it("stays disabled unless both settings are present", async () => { + const request = new Request("https://workshop.example/api", { + headers: { authorization: "Bearer deployment-secret" }, + }); + + await expect(verifyMachineAccess(request, { + COMPANY_OS_MACHINE_ADMIN_EMAIL: "admin@example.com", + })).resolves.toBeNull(); + await expect(verifyMachineAccess(request, { + COMPANY_OS_MACHINE_TOKEN: "deployment-secret", + })).resolves.toBeNull(); + }); +}); diff --git a/packages/workshop-backend/src/access.ts b/packages/workshop-backend/src/access.ts index 29801203..4c62450d 100644 --- a/packages/workshop-backend/src/access.ts +++ b/packages/workshop-backend/src/access.ts @@ -6,6 +6,12 @@ export type CfAccessEnv = Readonly<{ CF_ACCESS_ISS?: string; }>; +/** Machine API settings for trusted deployment automation. */ +export type MachineAccessEnv = Readonly<{ + COMPANY_OS_MACHINE_ADMIN_EMAIL?: string; + COMPANY_OS_MACHINE_TOKEN?: string; +}>; + type AccessTokenVerifier = (token: string, env: CfAccessEnv) => Promise; const remoteJwkSets = new Map>(); @@ -39,6 +45,29 @@ export async function verifyCfAccessJwt( } } +/** Returns the configured administrator email only for a valid machine bearer token. */ +export async function verifyMachineAccess( + request: Request, env: MachineAccessEnv): Promise { + const expected = env.COMPANY_OS_MACHINE_TOKEN; + const email = env.COMPANY_OS_MACHINE_ADMIN_EMAIL?.trim().toLowerCase(); + const authorization = request.headers.get("authorization"); + if (!expected || !email || !authorization?.startsWith("Bearer ")) return null; + + const supplied = authorization.slice("Bearer ".length); + const encoder = new TextEncoder(); + const [expectedDigest, suppliedDigest] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(expected)), + crypto.subtle.digest("SHA-256", encoder.encode(supplied)), + ]); + const expectedBytes = new Uint8Array(expectedDigest); + const suppliedBytes = new Uint8Array(suppliedDigest); + let difference = 0; + for (let index = 0; index < expectedBytes.length; index += 1) { + difference |= expectedBytes[index] ^ suppliedBytes[index]; + } + return difference === 0 ? email : null; +} + /** Returns a privacy-preserving limiter key derived only from verified Access claims. */ export async function accessRateLimitKey(payload: JWTPayload): Promise { if (payload.sub) return `access-sub:${payload.sub}`; diff --git a/packages/workshop-backend/src/analytics.ts b/packages/workshop-backend/src/analytics.ts index df6316a2..b48492e7 100644 --- a/packages/workshop-backend/src/analytics.ts +++ b/packages/workshop-backend/src/analytics.ts @@ -90,12 +90,12 @@ export type ProductAnalyticsInput = | { event_name: "account_created"; user_id: string; - source: "password" | "cf_access"; + source: "password" | "cf_access" | "machine_api"; } | { event_name: "user_authenticated"; user_id: string; - source: "password" | "cf_access" | "session_token"; + source: "password" | "cf_access" | "machine_api" | "session_token"; } | { event_name: "blueprint_imported"; diff --git a/packages/workshop-backend/src/env.d.ts b/packages/workshop-backend/src/env.d.ts index 14d29fd3..41e8f086 100644 --- a/packages/workshop-backend/src/env.d.ts +++ b/packages/workshop-backend/src/env.d.ts @@ -58,6 +58,10 @@ declare global { CF_ACCESS_AUD?: string; // audience CF_ACCESS_ISS?: string; // team URL, e.g. https://.cloudflareaccess.com + // Optional bearer-token identity for trusted deployment automation on the Worker subdomain. + COMPANY_OS_MACHINE_ADMIN_EMAIL?: string; + COMPANY_OS_MACHINE_TOKEN?: string; + // Comma-separated allowlist of gatekeeper vendor ids permitted to drive sign-in (e.g. // "google,github,cloudflare"). A listed gatekeeper must also advertise providesAuth. Empty = // no gatekeeper sign-in (password / CF Access only). diff --git a/packages/workshop-backend/src/server.ts b/packages/workshop-backend/src/server.ts index a5c7124b..0a96f208 100644 --- a/packages/workshop-backend/src/server.ts +++ b/packages/workshop-backend/src/server.ts @@ -24,7 +24,7 @@ import { ExternalMessageGateway } from "./external-message-gateway"; import { RpcStub as NativeRpcStub } from "cloudflare:workers"; import { recordAnalytics } from "./analytics"; import { handleClientErrorRequest } from "./client-errors.js"; -import { verifyCfAccessJwt } from "./access.js"; +import { verifyCfAccessJwt, verifyMachineAccess } from "./access.js"; import { resolveUiFeatureFlags } from "./feature-flags"; import { serveSiteLogo, SITE_LOGO_PATH } from "./site-logo.js"; import { createWorkshopLogger } from "./observability"; @@ -65,6 +65,8 @@ type Env = Cloudflare.Env & { // Set these if using Cloudflare Access for authentication, otherwise username/password is used. CF_ACCESS_AUD?: string, // audience CF_ACCESS_ISS?: string, // team URL, i.e. https://.cloudflareaccess.com + COMPANY_OS_MACHINE_ADMIN_EMAIL?: string; + COMPANY_OS_MACHINE_TOKEN?: string; DEV?: boolean; FLAGS?: Flagship; } @@ -626,7 +628,8 @@ class PublicApiImpl extends RpcTarget implements PublicApi { constructor(private ctx: ExecutionContext, private env: Env, private abortSession: (reason: Error) => void, - private accessPayload?: JWTPayload) { + private accessPayload?: JWTPayload, + private accessSource: "cf_access" | "machine_api" = "cf_access") { super(); this.users = this.ctx.exports.UserDurableObject; } @@ -692,13 +695,13 @@ class PublicApiImpl extends RpcTarget implements PublicApi { recordAnalytics(this.ctx, this.env, { event_name: "account_created", user_id: userId.toString(), - source: "cf_access", + source: this.accessSource, }); } recordAnalytics(this.ctx, this.env, { event_name: "user_authenticated", user_id: userId.toString(), - source: "cf_access", + source: this.accessSource, }); return new AuthenticatedApiImpl(this.ctx, this.env, stub, this.abortSession); } @@ -825,20 +828,25 @@ export default { } let accessPayload: JWTPayload | undefined; + let accessSource: "cf_access" | "machine_api" = "cf_access"; if (env.CF_ACCESS_AUD) { if (req.headers.get("Origin") !== url.origin) { return new Response("Cross-origin API access not allowed.", { status: 403 }); } - const payload = await verifyCfAccessJwt(req, env); - if (!payload) return new Response("Invalid CF access JWT.", { status: 403 }); + const machineEmail = await verifyMachineAccess(req, env); + const payload = machineEmail + ? { email: machineEmail, sub: `machine:${machineEmail}` } + : await verifyCfAccessJwt(req, env); + if (!payload) return new Response("Invalid API authentication.", { status: 403 }); if (!payload.email) { return new Response("Access JWT didn't specify email address.", { status: 403 }); } accessPayload = payload; + if (machineEmail) accessSource = "machine_api"; } // HACK: Implement `abortSession` callback by closing the websocket. @@ -851,7 +859,7 @@ export default { }; resp = await newWorkersRpcResponse(req, - new PublicApiImpl(ctx, env, abortSession, accessPayload)); + new PublicApiImpl(ctx, env, abortSession, accessPayload, accessSource)); if (aborted) { // Oops, we missed the abortSession() call while awaiting, apply now. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df0720a6..47d4fbfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(jsdom@26.1.0(supports-color@10.2.2))(vite@7.3.6(@types/node@26.1.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(yaml@2.9.0)) wrangler: specifier: ^4.115.0 version: 4.115.0 @@ -1101,7 +1104,7 @@ packages: optional: true '@cloudflare/kumo@2.9.0': - resolution: {integrity: sha512-WkJNwJrT/VDfBFckscrhnxKbAu9hRC/iWxzUB4tWu1OAHo3hx4PQ0cbT6wxoye54jns6m13Acq7SIzw5uY0Qcw==, tarball: https://registry.npmjs.org/@cloudflare/kumo/-/kumo-2.9.0.tgz} + resolution: {integrity: sha512-WkJNwJrT/VDfBFckscrhnxKbAu9hRC/iWxzUB4tWu1OAHo3hx4PQ0cbT6wxoye54jns6m13Acq7SIzw5uY0Qcw==} hasBin: true peerDependencies: '@phosphor-icons/react': ^2.1.10 @@ -1116,15 +1119,15 @@ packages: optional: true '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==, tarball: https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz} + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} '@cloudflare/puppeteer@1.2.0': - resolution: {integrity: sha512-2DKqSnB/TWVuoLTQzK5t0tQEoI+YGQYt/UisHCVr2fpW34D0H20n4HRmvtj/iVZDw0q2Y6LTqfEFh8ps9G/TiA==, tarball: https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-1.2.0.tgz} + resolution: {integrity: sha512-2DKqSnB/TWVuoLTQzK5t0tQEoI+YGQYt/UisHCVr2fpW34D0H20n4HRmvtj/iVZDw0q2Y6LTqfEFh8ps9G/TiA==} engines: {node: '>=18'} '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==, tarball: https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz} + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} peerDependencies: unenv: 2.0.0-rc.24 workerd: '>=1.20260623.1' @@ -1133,44 +1136,44 @@ packages: optional: true '@cloudflare/vitest-pool-workers@0.16.20': - resolution: {integrity: sha512-buw0YgsAMT7s60wcmyxbtciEJjMJzKcWzayDMPhWaqMqfQzW+0WPLV67Lobn4C80nkNQhYocEJPnrEhLWnOf+A==, tarball: https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.20.tgz} + resolution: {integrity: sha512-buw0YgsAMT7s60wcmyxbtciEJjMJzKcWzayDMPhWaqMqfQzW+0WPLV67Lobn4C80nkNQhYocEJPnrEhLWnOf+A==} peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 vitest: ^4.1.0 '@cloudflare/workerd-darwin-64@1.20260731.1': - resolution: {integrity: sha512-+fw/+9EinH1teyOM3IqdqNrmtrW4Ve20iyG6V4vPR9UhUkxTPQvffg8LXf/IFwdEBUfUTHXHX2W+qiGC1SXUzQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260731.1.tgz} + resolution: {integrity: sha512-+fw/+9EinH1teyOM3IqdqNrmtrW4Ve20iyG6V4vPR9UhUkxTPQvffg8LXf/IFwdEBUfUTHXHX2W+qiGC1SXUzQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260731.1': - resolution: {integrity: sha512-6LisxhmgX7Vb948xZpaM1o2o4liknkh881XwNzNf8c6fsA+Wi6BMbxq2uqd+Tnluo+tVxt91dNt9nE0E1VWFhg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260731.1.tgz} + resolution: {integrity: sha512-6LisxhmgX7Vb948xZpaM1o2o4liknkh881XwNzNf8c6fsA+Wi6BMbxq2uqd+Tnluo+tVxt91dNt9nE0E1VWFhg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20260731.1': - resolution: {integrity: sha512-jKU3YvgGkt84ppRLnQQjM+x4kh6+fAz1or4emiCw2upV4a4lQNvvdgOi9x1fshLzKsvGyRoZHWI9+QgzNPZTQA==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260731.1.tgz} + resolution: {integrity: sha512-jKU3YvgGkt84ppRLnQQjM+x4kh6+fAz1or4emiCw2upV4a4lQNvvdgOi9x1fshLzKsvGyRoZHWI9+QgzNPZTQA==} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260731.1': - resolution: {integrity: sha512-ajVOENiXNH4/5GwQ/BDSsTemjWFZmuGENhknv6ti1Efwj8JqBeSAUEwcQB73syjN+NtVb21GpEMox8VNex7Meg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260731.1.tgz} + resolution: {integrity: sha512-ajVOENiXNH4/5GwQ/BDSsTemjWFZmuGENhknv6ti1Efwj8JqBeSAUEwcQB73syjN+NtVb21GpEMox8VNex7Meg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260731.1': - resolution: {integrity: sha512-kf6LFxWRnZ2x0psXsmENSxwuBCFg59HilUQy07VCIfeEGeW89KfCvZxngBBmkYAXN9EpkTL3RY87ZxmjRaONsw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260731.1.tgz} + resolution: {integrity: sha512-kf6LFxWRnZ2x0psXsmENSxwuBCFg59HilUQy07VCIfeEGeW89KfCvZxngBBmkYAXN9EpkTL3RY87ZxmjRaONsw==} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workers-types@4.20260702.1': - resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==, tarball: https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz} + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} diff --git a/scripts/testdata/golden-manifest.json b/scripts/testdata/golden-manifest.json index d6f89704..87664473 100644 --- a/scripts/testdata/golden-manifest.json +++ b/scripts/testdata/golden-manifest.json @@ -373,6 +373,12 @@ "GoogleSheetsGatekeeperImpl" ], "tag": "v3" + }, + { + "new_sqlite_classes": [ + "GoogleDriveFolderGatekeeperImpl" + ], + "tag": "v4" } ], "modules": [