Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
19 changes: 14 additions & 5 deletions packages/gatekeeper-google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand All @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/gatekeeper-google/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -22,6 +23,7 @@
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wrangler": "^4.115.0"
}
}
Original file line number Diff line number Diff line change
@@ -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<ConfiguratorOption[]>;
}
Original file line number Diff line number Diff line change
@@ -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 <Section>
<Field label="Drive folder" description="Search folders that this Google account can access.">
<Autocomplete
name="folderId"
value={values.folderId}
placeholder="Search recent folders..."
loadOptions={query => ui.listFolders(query)}
onChange={folderId => setValues({ folderId })}
/>
</Field>
</Section>;
},
} satisfies ConfiguratorUISpec<DriveFolderConfiguratorRpc, DriveFolderConfiguratorValues>;
83 changes: 83 additions & 0 deletions packages/gatekeeper-google/src/drive-api.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
163 changes: 163 additions & 0 deletions packages/gatekeeper-google/src/drive-api.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<T>(response: Response): Promise<T> {
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<T>(url: URL, init: RequestInit = {}): Promise<T> {
let response = await fetchWithAuthRetry(
url.toString(), init, this.getAccessToken, { timeoutMs: REQUEST_TIMEOUT_MS },
);
return responseJson<T>(response);
}

async getFolder(folderId: string): Promise<DriveFolderInfo> {
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<DriveRestFile>(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<DriveFileInfo[]> {
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<DriveFileInfo> {
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<DriveRestFile>(url, {
method: "POST",
headers: { "Content-Type": `multipart/related; boundary=${boundary}` },
body,
}));
}
}
Loading
Loading