-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workspace-studio): Roux Ingest HTTP-mode add-on endpoint #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
4a4297c
feat(workspace-studio): Roux Ingest HTTP-mode add-on endpoint
chitcommit 0ac340c
Merge branch 'main' into feat/workspace-studio-roux-ingest
chitcommit 6398709
fix(workspace-jwt): verify systemIdToken audience against endpoint UR…
ab09e9e
fix(workspace-studio): merge classification + dispute_type, take stri…
fa3674c
fix(workspace-studio): atomic idempotency via partial unique index (P2)
4c458d6
fix(workspace-studio): forward gmail_message_id to storage ingest (P2)
7b0b5be
fix(workspace-studio): wrap outputs in hostAppAction per Studio contr…
c198c27
fix(channel-registry): enforce required capabilities on channel verif…
4726829
fix(workspace-studio): return config card as bare Card proto (P2)
c2d023e
fix(tests): honor SKIP_INTEGRATION in workspace-studio-ingest DB hooks
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Channel registry resolver. | ||
| * | ||
| * Per the global Channel Registration Protocol (CLAUDE.md), every channel/MCP | ||
| * client that talks to the ChittyOS ecosystem registers via | ||
| * `agent.chitty.cc/api/v1/channels/register` and receives back a ChittyID and | ||
| * capability manifest. This module is the chittycommand-side lookup. | ||
| * | ||
| * v1 (this file): env-var allowlist parsed from `REGISTERED_CHANNELS_JSON` | ||
| * (a JSON object keyed by channel_id). | ||
| * | ||
| * v2 (planned): HTTP call to `agent.chitty.cc/api/v1/channels/{channelId}` | ||
| * with the same return shape. See TODO below. | ||
| * | ||
| * @canon: chittycanon://core/services/chittycommand/channel-registry | ||
| */ | ||
|
|
||
| import type { Env } from '../index'; | ||
|
|
||
| export interface ChannelMeta { | ||
| channel_id: string; | ||
| chitty_id: string; | ||
| platform: string; | ||
| capabilities: string[]; | ||
| contact_endpoint?: string; | ||
| status: 'active' | 'suspended' | 'pending'; | ||
| } | ||
|
|
||
| interface ChannelRegistryEnv extends Pick<Env, 'COMMAND_KV'> { | ||
| REGISTERED_CHANNELS_JSON?: string; | ||
| } | ||
|
|
||
| /** | ||
| * The canonical Workspace Studio channel ChittyID — used when registering the | ||
| * Workspace Add-on as a channel under the universal protocol. Surface this | ||
| * value so it can be added to REGISTERED_CHANNELS_JSON. | ||
| */ | ||
| export const WORKSPACE_STUDIO_CHANNEL_ID = 'chitty:channel:workspace-studio-gmail'; | ||
|
|
||
| /** | ||
| * Resolve a channel's metadata, or null if the channel is not registered or | ||
| * not active. | ||
| * | ||
| * TODO(v2): Replace env-var lookup with a fetch to | ||
| * `agent.chitty.cc/api/v1/channels/{channelId}` and cache the result | ||
| * in `COMMAND_KV` under `channel:meta:{channel_id}` with a 5-minute | ||
| * TTL. Same return shape — callers do not need to change. | ||
| */ | ||
| export async function verifyRegisteredChannel( | ||
| channelId: string, | ||
| env: ChannelRegistryEnv, | ||
| ): Promise<ChannelMeta | null> { | ||
| if (!channelId) return null; | ||
|
|
||
| const raw = env.REGISTERED_CHANNELS_JSON; | ||
| if (!raw) return null; | ||
| let parsed: Record<string, ChannelMeta>; | ||
| try { | ||
| parsed = JSON.parse(raw) as Record<string, ChannelMeta>; | ||
| } catch { | ||
| console.warn('[channel-registry] REGISTERED_CHANNELS_JSON is not valid JSON'); | ||
| return null; | ||
| } | ||
| const meta = parsed[channelId]; | ||
| if (!meta) return null; | ||
| if (meta.status !== 'active') return null; | ||
| return meta; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| /** | ||
| * Google Workspace Add-on (HTTP mode) ID token verification. | ||
| * | ||
| * Google sends two ID tokens in the `authorizationEventObject` payload of every | ||
| * Workspace Studio custom-step invocation: | ||
| * | ||
| * - `systemIdToken` — signed assertion that THIS request originated from | ||
| * Google's Apps Script / Workspace Add-on infrastructure. The `email` | ||
| * claim is the marketplace service account, the `aud` claim is the | ||
| * OAuth client ID configured for the add-on. | ||
| * - `userIdToken` — signed assertion identifying the END-USER acting in | ||
| * Workspace. Same audience pinning as `systemIdToken`. | ||
| * | ||
| * Both are RS256-signed by Google. Public keys live at the standard JWKS | ||
| * endpoint `https://www.googleapis.com/oauth2/v3/certs`. | ||
| * | ||
| * Docs: | ||
| * https://developers.google.com/workspace/add-ons/concepts/http-overview#verifying_jwts | ||
| * | ||
| * Design notes: | ||
| * - The JWKS URL is injectable via `env.GCP_JWKS_URL` so tests can point at | ||
| * a local static-served JWKS without globally mocking fetch. | ||
| * - JWKS responses are cached in `COMMAND_KV` under `gcp:jwks` for 3600s | ||
| * (Google rotates these keys daily; we re-fetch on cache miss / expiry). | ||
| * | ||
| * @canon: chittycanon://core/services/chittycommand/workspace-studio | ||
| */ | ||
|
|
||
| import { jwtVerify, importJWK, type JWK } from 'jose'; | ||
| import type { Env } from '../index'; | ||
|
|
||
| const DEFAULT_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'; | ||
| const JWKS_KV_KEY = 'gcp:jwks'; | ||
| const JWKS_TTL_SECONDS = 3600; | ||
|
|
||
| const ALLOWED_ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']); | ||
|
|
||
| export class WorkspaceJWTError extends Error { | ||
| readonly code: string; | ||
| constructor(code: string, message: string) { | ||
| super(message); | ||
| this.code = code; | ||
| this.name = 'WorkspaceJWTError'; | ||
| } | ||
| } | ||
|
|
||
| interface CachedJWKS { | ||
| keys: JWK[]; | ||
| fetched_at: number; | ||
| } | ||
|
|
||
| interface WorkspaceEnv extends Pick<Env, 'COMMAND_KV'> { | ||
| CHITTYROUX_GCP_SA_EMAIL?: string; | ||
| CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID?: string; | ||
| GCP_JWKS_URL?: string; | ||
| } | ||
|
|
||
| async function getJWKS(env: WorkspaceEnv): Promise<JWK[]> { | ||
| const url = env.GCP_JWKS_URL ?? DEFAULT_JWKS_URL; | ||
|
|
||
| // KV cache first. | ||
| try { | ||
| const cached = await env.COMMAND_KV.get(JWKS_KV_KEY, { type: 'json' }) as CachedJWKS | null; | ||
| if (cached && Array.isArray(cached.keys) && cached.keys.length > 0) { | ||
| const age = Math.floor(Date.now() / 1000) - cached.fetched_at; | ||
| if (age < JWKS_TTL_SECONDS) return cached.keys; | ||
| } | ||
| } catch { | ||
| /* fall through to fetch */ | ||
| } | ||
|
|
||
| const res = await fetch(url, { headers: { Accept: 'application/json' } }); | ||
| if (!res.ok) { | ||
| throw new WorkspaceJWTError( | ||
| 'JWKS_FETCH_FAILED', | ||
| `Failed to fetch JWKS from ${url}: HTTP ${res.status}`, | ||
| ); | ||
| } | ||
| const body = await res.json() as { keys?: JWK[] }; | ||
| if (!body.keys || !Array.isArray(body.keys) || body.keys.length === 0) { | ||
| throw new WorkspaceJWTError('JWKS_MALFORMED', 'JWKS response missing keys'); | ||
| } | ||
| const payload: CachedJWKS = { keys: body.keys, fetched_at: Math.floor(Date.now() / 1000) }; | ||
| try { | ||
| await env.COMMAND_KV.put(JWKS_KV_KEY, JSON.stringify(payload), { expirationTtl: JWKS_TTL_SECONDS }); | ||
| } catch { | ||
| /* non-fatal — caching is opportunistic */ | ||
| } | ||
| return body.keys; | ||
| } | ||
|
|
||
| async function verifyWithJWKS( | ||
| token: string, | ||
| jwks: JWK[], | ||
| expectedAudience: string, | ||
| ): Promise<Record<string, unknown>> { | ||
| // jose has createRemoteJWKSet, but we cache through KV so we resolve the kid | ||
| // manually and importJWK for the matching entry. | ||
| const [headerB64] = token.split('.'); | ||
| if (!headerB64) { | ||
| throw new WorkspaceJWTError('TOKEN_MALFORMED', 'JWT missing header segment'); | ||
| } | ||
| let header: { kid?: string; alg?: string }; | ||
| try { | ||
| const json = atob(headerB64.replace(/-/g, '+').replace(/_/g, '/')); | ||
| header = JSON.parse(json); | ||
| } catch { | ||
| throw new WorkspaceJWTError('TOKEN_MALFORMED', 'JWT header is not valid JSON'); | ||
| } | ||
| const match = jwks.find((k) => k.kid === header.kid) ?? jwks[0]; | ||
| if (!match) { | ||
| throw new WorkspaceJWTError('JWKS_NO_MATCH', `No JWK matching kid=${header.kid}`); | ||
| } | ||
| const alg = header.alg || (match.alg as string) || 'RS256'; | ||
| const key = await importJWK(match, alg); | ||
| try { | ||
| const { payload } = await jwtVerify(token, key, { | ||
| audience: expectedAudience, | ||
| algorithms: [alg], | ||
| }); | ||
| return payload as Record<string, unknown>; | ||
| } catch (err) { | ||
| throw new WorkspaceJWTError( | ||
| 'TOKEN_INVALID', | ||
| `JWT verification failed: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| function requireIssuer(payload: Record<string, unknown>): void { | ||
| const iss = payload.iss; | ||
| if (typeof iss !== 'string' || !ALLOWED_ISSUERS.has(iss)) { | ||
| throw new WorkspaceJWTError( | ||
| 'ISSUER_INVALID', | ||
| `Issuer ${iss} is not a recognized Google issuer`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export interface WorkspaceTokenClaims { | ||
| sub: string; | ||
| email: string; | ||
| aud: string; | ||
| iss: string; | ||
| } | ||
|
|
||
| /** | ||
| * Verify Google's `systemIdToken`: the email claim MUST match the configured | ||
| * marketplace service account. | ||
| */ | ||
| export async function verifyWorkspaceSystemIdToken( | ||
| token: string, | ||
| env: WorkspaceEnv, | ||
| ): Promise<WorkspaceTokenClaims> { | ||
| const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID; | ||
|
chitcommit marked this conversation as resolved.
Outdated
|
||
| const expectedSa = env.CHITTYROUX_GCP_SA_EMAIL; | ||
| if (!expectedAud) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID not configured'); | ||
| if (!expectedSa) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_GCP_SA_EMAIL not configured'); | ||
|
|
||
| const jwks = await getJWKS(env); | ||
| const payload = await verifyWithJWKS(token, jwks, expectedAud); | ||
| requireIssuer(payload); | ||
|
|
||
| const email = typeof payload.email === 'string' ? payload.email : ''; | ||
| if (email !== expectedSa) { | ||
| throw new WorkspaceJWTError( | ||
| 'SA_MISMATCH', | ||
| `systemIdToken email ${email || '<missing>'} does not match expected SA`, | ||
| ); | ||
| } | ||
| return { | ||
| sub: String(payload.sub ?? ''), | ||
| email, | ||
| aud: String(payload.aud ?? ''), | ||
| iss: String(payload.iss ?? ''), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Verify Google's `userIdToken`: identifies the end-user. No SA pinning — the | ||
| * email claim is the human user. | ||
| */ | ||
| export async function verifyWorkspaceUserIdToken( | ||
| token: string, | ||
| env: WorkspaceEnv, | ||
| ): Promise<WorkspaceTokenClaims> { | ||
| const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID; | ||
| if (!expectedAud) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID not configured'); | ||
|
|
||
| const jwks = await getJWKS(env); | ||
| const payload = await verifyWithJWKS(token, jwks, expectedAud); | ||
| requireIssuer(payload); | ||
|
|
||
| const email = typeof payload.email === 'string' ? payload.email : ''; | ||
| if (!email) { | ||
| throw new WorkspaceJWTError('USER_EMAIL_MISSING', 'userIdToken missing email claim'); | ||
| } | ||
| return { | ||
| sub: String(payload.sub ?? ''), | ||
| email, | ||
| aud: String(payload.aud ?? ''), | ||
| iss: String(payload.iss ?? ''), | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Google starts signing tokens with a new
kidwhilegcp:jwksstill contains an older cached key set, this code falls back tojwks[0]and immediately verifies with the wrong key instead of treating the missing kid as a cache miss. Legitimate Workspace requests signed by the new key can be rejected for up to the one-hour KV TTL; require an exact kid match and refresh the JWKS before failing.Useful? React with 👍 / 👎.