Skip to content

Commit b9e224a

Browse files
serge-the-devclaude
andcommitted
feat(connectors): declare Drive, WorkDrive and Gmail, and expose the catalog (#352 Stage 1)
The registry has been the single source of truth for connectors since #86, but only from inside the Worker and only for connectors that were in it. These three never were, so "which connectors exist, and is this one connected" had five independent answers: routes/drive.ts + workdrive.ts, three hand-written console blocks, an MCP PROVIDERS map with a matching Zod enum, the sync_connector trigger branch, and the apology comment in client.ts. A sixth connector of this kind meant editing all five. Declaring them moves no data. All three already store their refresh token through saveConnectorRefreshToken into user_api_keys(user_id, provider=<id>) — byte-for-byte the row connectors/client.ts reads for an auth:"oauth" registry connector — and Drive/WorkDrive already write instance_connector_grants under the same ids. So no backfill, no rename, no dual-write window: existing connections keep working because nothing about the stored row changes. The ids are deliberately left alone; renaming them to satisfy the manifest validator's ID_RE is the only change in this area that would touch live rows, and it would buy eligibility for a validator these connectors cannot pass anyway (grantModel is not a manifest field, and custom handlers are rejected). This is a registry declaration, not a manifest. - `tools: []` on all three, so connectorTools() is unchanged and no agent gains anything. Drive/WorkDrive are ingestion sources, not things an agent calls mid-turn; inventing drive_read_file purely so capabilities.tools has something to gate would add a second content path that bypasses the vector store and a second place the folder grant must be enforced per call. Gmail's find_confirmation_link is permission-gated and deliberately not creator-selectable — the registry has no category for that, and inventing one to fit it would soften the gate. - `GET /v1/connectors` — what each connector IS (auth, scopes, grantModel, unattended, tools) and where the caller stands (`configured` vs `connected`, the #353 distinction). The next consumer is a .map() rather than a sixth copy. - client.ts's hardcoded google_drive OAuth branch is gone: it resolves from its own declaration now, like everything else. Deleted rather than left as a fallback — two answers for one connector is what the declaration removes. - WorkDrive declares NO oauth block on purpose. Its endpoints are per data-centre, so there is no static URL; omitting it keeps the generic route 404ing rather than building an authorize URL against the wrong DC. - DELETE /v1/connectors/:id/oauth now cascades grants for instance-resource connectors. Declaring Drive/WorkDrive made this route a SECOND way to disconnect them, which without the cascade would have re-opened #357 through another door. Deployment caveat, unchanged from google_sheets: the generic flow's callback is /v1/connectors/:id/oauth/callback, which these OAuth apps do not have registered. Nothing links it; the dedicated flows stay live until Stage 2 retires them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c854ea0 commit b9e224a

8 files changed

Lines changed: 407 additions & 22 deletions

File tree

‎store/openapi.yaml‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8762,6 +8762,52 @@ paths:
87628762

87638763
# ── Generic connector OAuth ───────────────────────────────────────────────
87648764

8765+
/v1/connectors:
8766+
get:
8767+
summary: The connector catalog, resolved for the caller
8768+
description: |
8769+
Every connector the platform declares, with what it IS (auth model, scopes, grant model,
8770+
unattended class, its tools) and where the caller stands with it (`configured` — does
8771+
this deployment have credentials at all; `connected` — has the caller connected it, or
8772+
`null` for a connector that holds no credential).
8773+
8774+
Added because the registry has been the single source of truth for connectors since #86
8775+
but only from inside the Worker, so every consumer that needed "which connectors exist
8776+
and is this one connected" kept its own list — two route files, three hand-written
8777+
console blocks, an MCP provider map with a matching enum, and the folder-sync trigger
8778+
branch. This is the one answer they derive from.
8779+
operationId: listConnectors
8780+
tags: [Connectors]
8781+
responses:
8782+
"200":
8783+
description: The catalog
8784+
content:
8785+
application/json:
8786+
schema:
8787+
type: object
8788+
properties:
8789+
connectors:
8790+
type: array
8791+
items:
8792+
type: object
8793+
properties:
8794+
id: { type: string, example: google_drive }
8795+
label: { type: string, example: Google Drive }
8796+
auth: { type: string, enum: [oauth, token, app, none] }
8797+
scopes:
8798+
type: object
8799+
properties:
8800+
read: { type: boolean }
8801+
write: { type: boolean }
8802+
grantModel: { type: string, enum: [user, instance-resource] }
8803+
unattended: { type: string, enum: [yes, refresh, interactive-only] }
8804+
tools: { type: array, items: { type: string } }
8805+
configured: { type: boolean, description: Whether this deployment can offer the connector at all. }
8806+
connected: { type: boolean, nullable: true, description: "Whether the caller has connected it; null when the connector holds no credential (relay/no-auth)." }
8807+
account: { type: string, nullable: true }
8808+
connectedAt: { type: string, nullable: true }
8809+
"401": { $ref: "#/components/responses/Unauthorized" }
8810+
87658811
/v1/connectors/{id}/oauth/start:
87668812
get:
87678813
summary: Begin a connector's OAuth flow
@@ -8834,8 +8880,12 @@ paths:
88348880
type: object
88358881
properties:
88368882
connected: { type: boolean }
8883+
account: { type: string, nullable: true }
88378884
connectedAt: { type: string, nullable: true }
88388885
configured: { type: boolean, description: Whether this deployment has the connector's OAuth credentials at all. }
8886+
reach:
8887+
allOf: [{ $ref: "#/components/schemas/ConnectorReach" }]
8888+
description: Present only for `grantModel: instance-resource` — what a disconnect would revoke.
88398889
"401": { $ref: "#/components/responses/Unauthorized" }
88408890
"404":
88418891
description: No OAuth connector with that id
@@ -8846,7 +8896,11 @@ paths:
88468896
/v1/connectors/{id}/oauth:
88478897
delete:
88488898
summary: Disconnect a connector
8849-
description: Drops the stored refresh token.
8899+
description: >
8900+
Drops the stored refresh token, and — for a `grantModel: instance-resource` connector —
8901+
every per-instance grant the caller made for it, reported as `revoked`. Without the
8902+
cascade this route would be a second disconnect path that leaves grants standing for the
8903+
next reconnect to restore.
88508904
operationId: connectorOauthDisconnect
88518905
tags: [Connectors]
88528906
parameters:
@@ -8856,7 +8910,11 @@ paths:
88568910
description: Disconnected
88578911
content:
88588912
application/json:
8859-
schema: { $ref: "#/components/schemas/Success" }
8913+
schema:
8914+
type: object
8915+
properties:
8916+
success: { type: boolean }
8917+
revoked: { $ref: "#/components/schemas/ConnectorReach" }
88608918
"401": { $ref: "#/components/responses/Unauthorized" }
88618919

88628920
# ── Gmail ─────────────────────────────────────────────────────────────────

‎workers/api/src/lib/connectors/client.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,18 @@ import { requireConnectorGrant } from "../connector-grants.js";
1616

1717
const FIXTURES: Record<string, Connector> = {
1818
app_conn: { id: "app_conn", label: "App Conn", auth: "app", scopes: { read: true, write: true }, grantModel: "user", tools: [] },
19-
oauth_conn: { id: "google_drive", label: "Drive", auth: "oauth", scopes: { read: true, write: false }, grantModel: "user", tools: [] },
19+
// The oauth config is DECLARED (#352 Stage 1) — `resolveOauthConfig` has no per-connector
20+
// branch any more, so a fixture that omits it now resolves to no credentials, which is the
21+
// correct answer for a connector that declares none.
22+
oauth_conn: {
23+
id: "google_drive",
24+
label: "Drive",
25+
auth: "oauth",
26+
scopes: { read: true, write: false },
27+
grantModel: "user",
28+
oauth: { authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", clientIdEnv: "GOOGLE_CLIENT_ID", secretEnv: "GOOGLE_CLIENT_SECRET" },
29+
tools: [],
30+
},
2031
env_token_conn: { id: "env_token_conn", label: "Env Token", auth: "token", scopes: { read: false, write: true }, grantModel: "user", tokenEnv: "META_ACCESS_TOKEN", tools: [] },
2132
user_token_conn: { id: "user_token_conn", label: "User Token", auth: "token", scopes: { read: true, write: false }, grantModel: "user", tools: [] },
2233
none_conn: { id: "none_conn", label: "None", auth: "none", scopes: { read: true, write: true }, grantModel: "user", tools: [] },

‎workers/api/src/lib/connectors/client.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@ interface OauthClientCreds {
2626
tokenUrl?: string;
2727
}
2828

29-
/** Resolve a connector's OAuth token endpoint + client credentials. Manifest connectors carry
30-
* their config on `Connector.oauth` (endpoint + env-var names for the credentials); the hand-
31-
* written google_drive connector keeps its hardcoded creds. `resolveOauthConfig` is exported so
32-
* the generic authorize/callback route (routes/connectors.ts) resolves the same way. */
29+
/** Resolve a connector's OAuth token endpoint + client credentials, from the connector's own
30+
* declaration (endpoint + the env-var NAMES holding the credentials; the values are resolved
31+
* server-side and never appear in a manifest). Exported so the generic authorize/callback route
32+
* (routes/connectors.ts) resolves exactly the same way a token mint does.
33+
*
34+
* `google_drive` used to be special-cased here with hardcoded creds, because it was the one
35+
* OAuth connector that existed outside the registry. It is declared now (#352 Stage 1,
36+
* connected-accounts.ts) and resolves through the same path as everything else — the branch was
37+
* removed rather than left as a redundant fallback, so there is one answer per connector. */
3338
export function resolveOauthConfig(env: Env, connectorId: string): OauthClientCreds {
34-
if (connectorId === "google_drive") {
35-
return { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, tokenUrl: "https://oauth2.googleapis.com/token" };
36-
}
3739
const oauth = getConnector(connectorId)?.oauth;
3840
if (!oauth) return {};
3941
const e = env as unknown as Record<string, string | undefined>;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* The three connected ACCOUNTS — Google Drive, Zoho WorkDrive, Gmail (#352 Stage 1).
3+
*
4+
* These have been connectors in every respect except the registry: each stores its refresh
5+
* token through `saveConnectorRefreshToken` into `user_api_keys(user_id, provider=<id>)`,
6+
* which is byte-for-byte the row `connectors/client.ts` reads for an `auth:"oauth"` registry
7+
* connector, and Drive/WorkDrive already write `instance_connector_grants` under the same ids.
8+
* So declaring them is a DECLARATION, not a migration: there is no backfill, no rename, no
9+
* dual-write window. Existing connections keep working because nothing about the stored row
10+
* changes.
11+
*
12+
* `tools: []` is the point, not an omission. None of the three is a thing an agent calls
13+
* mid-turn:
14+
*
15+
* Drive / WorkDrive are INGESTION SOURCES. The user imports, or a `sync_connector` trigger
16+
* does, and the content then lives in the instance's own store where RAG and every other
17+
* surface already see it. Inventing `drive_read_file` so `capabilities.tools` has something
18+
* to gate would add a second content path into the model that bypasses the vector store, and
19+
* a second place the folder grant must be enforced per call — for a connector whose entire
20+
* security story today is one grant check at the top of a walk that cannot leave its root.
21+
*
22+
* Gmail's tool (`find_confirmation_link`) exists but is deliberately NOT creator-selectable:
23+
* it is granted at runtime by the owner's `AgentState.permissions.email` flag. The registry
24+
* has no category for that — `grantModel` is `user | instance-resource` and neither means
25+
* "granted by an owner-set permission flag" — so folding it in would mean inventing a third
26+
* grant model or softening the gate. It stays where it is.
27+
*
28+
* What the declaration buys is that "which connectors exist, and what are they" has ONE answer
29+
* (`GET /v1/connectors`) instead of five independent copies of "google_drive and zoho_workdrive
30+
* are the file connectors": two route files, three hand-written console blocks, an MCP
31+
* `PROVIDERS` map with a matching Zod enum, the `sync_connector` trigger branch, and the
32+
* apology comment in `client.ts`.
33+
*
34+
* A caveat that is real and unchanged from `google_sheets`: declaring `oauth` makes the generic
35+
* `/v1/connectors/:id/oauth/start` able to build an authorize URL whose `redirect_uri` is
36+
* `/v1/connectors/<id>/oauth/callback` — a path these providers' OAuth apps do not have
37+
* registered, so it would fail at the provider. The console does not link it; the dedicated
38+
* flows (`/v1/drive/google/start`, …) remain the live ones until #352 Stage 2 registers the
39+
* generic redirect and retires them.
40+
*/
41+
import { DRIVE_SCOPE } from "../drive.js";
42+
import { GMAIL_SCOPE } from "../gmail.js";
43+
import type { Connector } from "./types.js";
44+
45+
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
46+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
47+
48+
export const GOOGLE_DRIVE_CONNECTOR: Connector = {
49+
id: "google_drive",
50+
label: "Google Drive",
51+
auth: "oauth",
52+
// DRIVE_SCOPE is `drive.readonly`. There is no write path in lib/drive.ts and there is not
53+
// meant to be one, so `scopes.write:false` is enforceable truth: connectorClient refuses a
54+
// write-scoped token outright, and a read-only connector can never be write-consented (#90).
55+
scopes: { read: true, write: false },
56+
// The grant model this connector already implements by hand: `requireConnectorGrant` on every
57+
// route, and the sync walk re-checking descent from the granted root.
58+
grantModel: "instance-resource",
59+
oauth: {
60+
authUrl: GOOGLE_AUTH_URL,
61+
tokenUrl: GOOGLE_TOKEN_URL,
62+
// `openid email` is what lets the status route say WHICH account is connected.
63+
scopes: ["openid", "email", DRIVE_SCOPE],
64+
clientIdEnv: "GOOGLE_CLIENT_ID",
65+
secretEnv: "GOOGLE_CLIENT_SECRET",
66+
},
67+
tools: [],
68+
};
69+
70+
export const ZOHO_WORKDRIVE_CONNECTOR: Connector = {
71+
id: "zoho_workdrive",
72+
label: "Zoho WorkDrive",
73+
auth: "oauth",
74+
scopes: { read: true, write: false },
75+
grantModel: "instance-resource",
76+
// No `oauth` block ON PURPOSE. Zoho's authorize/token endpoints are per data-centre
77+
// (`workDriveAccountsBase(env)` reads ZOHO_ACCOUNTS_BASE), so there is no static URL to
78+
// declare and the manifest shape cannot express one. Omitting it keeps the generic OAuth
79+
// routes answering 404 for this id rather than minting an authorize URL against the wrong
80+
// DC — the declaration says what is true, including the part that is not yet generic.
81+
tools: [],
82+
};
83+
84+
export const GMAIL_CONNECTOR: Connector = {
85+
id: "gmail",
86+
label: "Gmail",
87+
auth: "oauth",
88+
scopes: { read: true, write: false },
89+
// `user`, not `instance-resource`: there is no per-mailbox grant row. Reach is decided by the
90+
// per-agent `permissions.email` flag, which lives in that agent's own state — see the header.
91+
grantModel: "user",
92+
oauth: {
93+
authUrl: GOOGLE_AUTH_URL,
94+
tokenUrl: GOOGLE_TOKEN_URL,
95+
scopes: ["openid", "email", GMAIL_SCOPE],
96+
clientIdEnv: "GOOGLE_CLIENT_ID",
97+
secretEnv: "GOOGLE_CLIENT_SECRET",
98+
},
99+
tools: [],
100+
};

‎workers/api/src/lib/connectors/registry.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,53 @@ import { describe, expect, it } from "vitest";
22
import { CONNECTORS, connectorTools, getConnector } from "./registry.js";
33

44
describe("connector registry", () => {
5-
it("declares browser, github, google_sheets, http, mcp, meta, repo-local, supervision, terminal, tmux, and web-search", () => {
5+
it("declares every connector the platform has, including the three tool-less accounts", () => {
66
const ids = CONNECTORS.map((c) => c.id).sort();
7-
expect(ids).toEqual(["browser", "github", "google_sheets", "http", "mcp", "meta", "repo-local", "supervision", "terminal", "tmux", "web-search"]);
7+
expect(ids).toEqual([
8+
"browser", "github", "gmail", "google_drive", "google_sheets", "http", "mcp",
9+
"meta", "repo-local", "supervision", "terminal", "tmux", "web-search", "zoho_workdrive",
10+
]);
11+
});
12+
13+
// #352 Stage 1. Drive/WorkDrive/Gmail already stored their refresh token in the exact row an
14+
// `auth:"oauth"` registry connector reads — `user_api_keys(user_id, provider=<id>)` — so
15+
// declaring them moved no data. These assertions pin the two properties that made it safe:
16+
// the ids are the ones already in live rows, and nothing gained a tool.
17+
describe("the three connected accounts (#352 Stage 1)", () => {
18+
it("keeps the ids that are already in user_api_keys and instance_connector_grants", () => {
19+
expect(getConnector("google_drive")?.id).toBe("google_drive");
20+
expect(getConnector("zoho_workdrive")?.id).toBe("zoho_workdrive");
21+
expect(getConnector("gmail")?.id).toBe("gmail");
22+
});
23+
24+
it("declares no tools, so connectorTools() is unchanged and no agent gains reach", () => {
25+
for (const id of ["google_drive", "zoho_workdrive", "gmail"]) {
26+
expect(getConnector(id)?.tools).toEqual([]);
27+
}
28+
expect(connectorTools().some((t) => ["google_drive", "zoho_workdrive", "gmail"].includes(t.connector ?? ""))).toBe(false);
29+
});
30+
31+
it("is read-only across all three — there is no write path in drive.ts/workdrive.ts/gmail.ts", () => {
32+
for (const id of ["google_drive", "zoho_workdrive", "gmail"]) {
33+
expect(getConnector(id)?.scopes).toEqual({ read: true, write: false });
34+
}
35+
});
36+
37+
it("models Drive/WorkDrive reach as instance-resource and Gmail's as user", () => {
38+
expect(getConnector("google_drive")?.grantModel).toBe("instance-resource");
39+
expect(getConnector("zoho_workdrive")?.grantModel).toBe("instance-resource");
40+
// Gmail has no grant row; its reach is the per-agent permissions.email flag, which the
41+
// registry has no model for — see connected-accounts.ts for why it is not invented here.
42+
expect(getConnector("gmail")?.grantModel).toBe("user");
43+
});
44+
45+
it("declares WorkDrive with NO oauth block — its endpoints are per data-centre", () => {
46+
// Not an omission: `workDriveAccountsBase(env)` is env-dependent, so there is no static
47+
// authorize URL to declare. Omitting it keeps the generic OAuth route 404ing for this id
48+
// instead of building a URL against the wrong DC.
49+
expect(getConnector("zoho_workdrive")?.oauth).toBeUndefined();
50+
expect(getConnector("google_drive")?.oauth?.tokenUrl).toBe("https://oauth2.googleapis.com/token");
51+
});
852
});
953

1054
// repo-local is the only read-ONLY connector: scopes.write:false is what makes it

‎workers/api/src/lib/connectors/registry.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { Env } from "../../types.js";
66
import type { Connector, EnvTokenKey, ToolDef } from "./types.js";
77
import { BROWSER_TOOLS } from "./browser.js";
8+
import { GMAIL_CONNECTOR, GOOGLE_DRIVE_CONNECTOR, ZOHO_WORKDRIVE_CONNECTOR } from "./connected-accounts.js";
89
import { GITHUB_CONNECTOR } from "./github.js";
910
import { HTTP_TOOLS } from "./http.js";
1011
import { MCP_TOOLS } from "./mcp.js";
@@ -135,6 +136,14 @@ export const CONNECTORS: Connector[] = [
135136
// /v1/connectors/:id/oauth flow (#147), read + append rows. Inert until the Google OAuth app
136137
// has the spreadsheets scope + the connector callback registered.
137138
GOOGLE_SHEETS_CONNECTOR,
139+
// The three connected ACCOUNTS (#352 Stage 1) — declared with `tools: []`, so
140+
// `connectorTools()` is byte-identical and no agent gains anything. They were already
141+
// connectors in storage (same `user_api_keys` row, same `instance_connector_grants`); this
142+
// is the registry catching up to that, so "what connectors exist" stops having five answers.
143+
// Why they have no tools, and why Gmail's is not moved: connected-accounts.ts.
144+
GOOGLE_DRIVE_CONNECTOR,
145+
ZOHO_WORKDRIVE_CONNECTOR,
146+
GMAIL_CONNECTOR,
138147
];
139148

140149
const BY_ID: ReadonlyMap<string, Connector> = new Map(CONNECTORS.map((c) => [c.id, c] as const));

0 commit comments

Comments
 (0)