From 4a4297cc976f3dccd9e17cacb70ee5985bc6c021 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:07:17 +0000 Subject: [PATCH 1/9] feat(workspace-studio): Roux Ingest HTTP-mode add-on endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the ChittyRoux x Workspace Studio integration. chittycommand IS the Workspace Add-on (HTTP mode, not Apps Script). New endpoints under /workspace/studio/roux-ingest: POST /config — single-card config (TextInputs for endpoint + default privilege) POST /execute — verifies systemIdToken + userIdToken, looks up channel in REGISTERED_CHANNELS_JSON, derives Roux from classification, creates Goal→Plan→Intent chain with privilege+space tagged at creation, applies the privileged/pii/legalink suppression gate, fans out storage_ingest + addCustodyEntry + classifyDispute under c.executionCtx.waitUntil to stay under the 30s ceiling. Idempotency by Gmail message_id via JSON-path lookup on cc_intents.payload->'source'->>'message_id'. JWKS verifier is injectable (env.GCP_JWKS_URL) so tests run against a local RS256 keypair without mocking global fetch. JWKS responses cache in COMMAND_KV under gcp:jwks with 3600s TTL. Channel registry is v1 env-var lookup; v2 will hit agent.chitty.cc/api/v1/channels/{id} — TODO inline. Canonical channel ID: chitty:channel:workspace-studio-gmail. Wrangler bindings (CHITTYROUX_GCP_SA_EMAIL, CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID, CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET, REGISTERED_CHANNELS_JSON) are deliberately NOT pushed in this PR — concierge round-4 lands them separately. Tests: 4 JWT tests pass without DB; 5 route tests skip without DATABASE_URL, matching the established pattern in tests/routes/triage-roux.spec.ts. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 +- package.json | 1 + src/index.ts | 14 + src/lib/channel-registry.ts | 68 +++ src/lib/workspace-jwt.ts | 204 +++++++++ src/middleware/workspace-auth.ts | 98 +++++ src/routes/workspace-studio.ts | 423 +++++++++++++++++++ tests/routes/workspace-studio-ingest.spec.ts | 363 ++++++++++++++++ 8 files changed, 1175 insertions(+), 4 deletions(-) create mode 100644 src/lib/channel-registry.ts create mode 100644 src/lib/workspace-jwt.ts create mode 100644 src/middleware/workspace-auth.ts create mode 100644 src/routes/workspace-studio.ts create mode 100644 tests/routes/workspace-studio-ingest.spec.ts diff --git a/package-lock.json b/package-lock.json index 0e59a87..5787c41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "drizzle-orm": "^0.45.2", "hono": "^4.12.23", "hono-agents": "^3.0.7", + "jose": "^6.2.3", "workers-ai-provider": "^3.1.8", "zod": "^4.3.6" }, @@ -3225,11 +3226,10 @@ "peer": true }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } diff --git a/package.json b/package.json index e2f2c46..61cc381 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "drizzle-orm": "^0.45.2", "hono": "^4.12.23", "hono-agents": "^3.0.7", + "jose": "^6.2.3", "workers-ai-provider": "^3.1.8", "zod": "^4.3.6" }, diff --git a/src/index.ts b/src/index.ts index 8e992b8..cf4b77d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { jobRoutes } from './routes/jobs'; import { transactionRoutes } from './routes/transactions'; import { timelineRoutes } from './routes/timeline'; import { triageRoutes } from './routes/triage'; +import { workspaceStudioRoutes } from './routes/workspace-studio'; // Re-export ActionAgent DO class so the runtime can find it export { ActionAgent } from './agents/action-agent'; @@ -70,6 +71,13 @@ export type Env = { PLAID_CLIENT_ID?: string; PLAID_SECRET?: string; PLAID_ENV?: string; + // ChittyRoux × Workspace Studio integration + // @canon: chittycanon://core/services/chittycommand/workspace-studio + CHITTYROUX_GCP_SA_EMAIL?: string; + CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID?: string; + CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET?: string; + REGISTERED_CHANNELS_JSON?: string; + GCP_JWKS_URL?: string; }; const app = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); @@ -167,6 +175,12 @@ app.route('/api/v1', timelineRoutes); app.use('/agent/*', authMiddleware); app.use('/agent/*', agentsMiddleware()); +// Workspace Studio HTTP-mode add-on endpoints (Roux Ingest custom step). +// Auth is per-request via authorizationEventObject JWT verification — NOT +// the global /api/* authMiddleware. Mount outside /api/*. +// @canon: chittycanon://core/services/chittycommand/workspace-studio +app.route('/workspace/studio/roux-ingest', workspaceStudioRoutes); + // MCP server — authenticated via shared token in KV app.use('/mcp/*', mcpAuthMiddleware); app.route('/mcp', mcpRoutes); diff --git a/src/lib/channel-registry.ts b/src/lib/channel-registry.ts new file mode 100644 index 0000000..ce653bf --- /dev/null +++ b/src/lib/channel-registry.ts @@ -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 { + 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 { + if (!channelId) return null; + + const raw = env.REGISTERED_CHANNELS_JSON; + if (!raw) return null; + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } 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; +} diff --git a/src/lib/workspace-jwt.ts b/src/lib/workspace-jwt.ts new file mode 100644 index 0000000..80a85dc --- /dev/null +++ b/src/lib/workspace-jwt.ts @@ -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 { + CHITTYROUX_GCP_SA_EMAIL?: string; + CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID?: string; + GCP_JWKS_URL?: string; +} + +async function getJWKS(env: WorkspaceEnv): Promise { + 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> { + // 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; + } catch (err) { + throw new WorkspaceJWTError( + 'TOKEN_INVALID', + `JWT verification failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function requireIssuer(payload: Record): 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 { + const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID; + 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 || ''} 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 { + 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 ?? ''), + }; +} diff --git a/src/middleware/workspace-auth.ts b/src/middleware/workspace-auth.ts new file mode 100644 index 0000000..51e7053 --- /dev/null +++ b/src/middleware/workspace-auth.ts @@ -0,0 +1,98 @@ +/** + * Hono middleware: verify Google Workspace Add-on HTTP-mode auth payload. + * + * Workspace Studio sends an `authorizationEventObject` inside the JSON body + * of every custom-step invocation. We verify the system ID token (proves the + * request came from Google's infrastructure) and the user ID token (identifies + * the end-user), then stash the resolved context on the Hono context for + * downstream handlers. + * + * @canon: chittycanon://core/services/chittycommand/workspace-studio + */ + +import type { Context, Next, MiddlewareHandler } from 'hono'; +import type { Env } from '../index'; +import { + verifyWorkspaceSystemIdToken, + verifyWorkspaceUserIdToken, + WorkspaceJWTError, +} from '../lib/workspace-jwt'; + +export interface WorkspaceContext { + user_email: string; + user_oauth_token: string | null; + sa_email: string; +} + +export type WorkspaceVariables = { + workspaceContext: WorkspaceContext; + workspaceBody: Record; +}; + +interface AuthorizationEventObject { + systemIdToken?: string; + userIdToken?: string; + userOAuthToken?: string; +} + +function googleErrorCard(message: string, code: string) { + return { + renderActions: { + action: { + notifications: [ + { + text: `chittycommand auth failed (${code}): ${message}`, + }, + ], + }, + }, + error: { code, message }, + }; +} + +export function workspaceAuth(): MiddlewareHandler<{ + Bindings: Env; + Variables: WorkspaceVariables; +}> { + return async (c: Context<{ Bindings: Env; Variables: WorkspaceVariables }>, next: Next) => { + let body: Record; + try { + body = await c.req.json(); + } catch { + return c.json(googleErrorCard('Body is not valid JSON', 'BAD_BODY'), 400); + } + + const authEvt = (body.authorizationEventObject ?? {}) as AuthorizationEventObject; + const systemIdToken = authEvt.systemIdToken; + const userIdToken = authEvt.userIdToken; + const userOAuthToken = authEvt.userOAuthToken ?? null; + + if (!systemIdToken || !userIdToken) { + return c.json( + googleErrorCard( + 'authorizationEventObject must include systemIdToken and userIdToken', + 'TOKENS_MISSING', + ), + 401, + ); + } + + try { + const sysClaims = await verifyWorkspaceSystemIdToken(systemIdToken, c.env); + const userClaims = await verifyWorkspaceUserIdToken(userIdToken, c.env); + + c.set('workspaceContext', { + user_email: userClaims.email, + user_oauth_token: userOAuthToken, + sa_email: sysClaims.email, + }); + c.set('workspaceBody', body); + } catch (err) { + if (err instanceof WorkspaceJWTError) { + return c.json(googleErrorCard(err.message, err.code), 401); + } + return c.json(googleErrorCard(String(err), 'AUTH_UNKNOWN'), 401); + } + return next(); + }; +} diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts new file mode 100644 index 0000000..7e11e36 --- /dev/null +++ b/src/routes/workspace-studio.ts @@ -0,0 +1,423 @@ +/** + * Workspace Studio HTTP endpoints — Roux Ingest custom step. + * + * chittycommand IS the Workspace Add-on backend (HTTP-mode add-ons are GA; + * no Apps Script project). Google's Workspace Studio invokes these endpoints + * directly when a workflow author drops the "Roux Ingest" custom step into + * a Gmail-triggered routine. + * + * POST /workspace/studio/roux-ingest/config — render config card + * POST /workspace/studio/roux-ingest/execute — run the step + * + * Both endpoints expect the standard Workspace HTTP payload with an + * `authorizationEventObject` containing systemIdToken / userIdToken / + * userOAuthToken — verified by the workspaceAuth middleware. + * + * Response shape: literal JSON matching the Google Apps Card v1 / RenderActions + * proto. We do NOT use Apps Script SDK methods (setLog, TextFormatChip, etc.) + * because this is HTTP mode. + * ref: https://developers.google.com/workspace/add-ons/concepts/http-overview + * + * @canon: chittycanon://core/services/chittycommand/workspace-studio + */ + +import { Hono } from 'hono'; +import type { Env } from '../index'; +import { workspaceAuth, type WorkspaceVariables } from '../middleware/workspace-auth'; +import { + verifyRegisteredChannel, + WORKSPACE_STUDIO_CHANNEL_ID, +} from '../lib/channel-registry'; +import { createIntent, createGoal, createPlan } from '../../meta/intent'; +import { deriveRouxFromType } from '../lib/dispute-sync'; +import { getDb } from '../lib/db'; +import { evidenceClient, routerClient } from '../lib/integrations'; + +export const workspaceStudioRoutes = new Hono<{ + Bindings: Env; + Variables: WorkspaceVariables; +}>(); + +// ── Config card ───────────────────────────────────────────────────────── +// Returned to Workspace Studio when a workflow author opens the custom step +// settings. Single-card limitation: no nav, no multi-step. +workspaceStudioRoutes.post('/config', async (c) => { + // Config endpoint does not require user auth — the workflow author is + // already authenticated to Workspace. Google still sends a system token, + // but we don't gate the config preview on it. + return c.json({ + renderActions: { + action: { + navigations: [ + { + pushCard: { + sections: [ + { + header: 'ChittyCommand — Roux Ingest', + widgets: [ + { + textParagraph: { + text: + 'Routes the triggering Gmail message into ChittyCommand as a triage intent. ' + + 'ChittyRoux derives privilege (privileged/pii/hoa_evidentiary/public) and ' + + 'space (business/legalink) from message classification and applies the gate.', + }, + }, + { + textInput: { + name: 'chittycommand_url', + label: 'ChittyCommand endpoint', + value: 'https://command.chitty.cc', + }, + }, + { + textInput: { + name: 'default_privilege', + label: 'Default privilege if classification fails', + value: 'public', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }); +}); + +// ── Execute step ──────────────────────────────────────────────────────── + +workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { + const body = c.get('workspaceBody') as Record; + const wsCtx = c.get('workspaceContext'); + + // Channel registration check. + const channelId = + extractScalar(body, 'channel_id') ?? + extractInputScalar(body, 'channel_id') ?? + WORKSPACE_STUDIO_CHANNEL_ID; + const channel = await verifyRegisteredChannel(channelId, c.env); + if (!channel) { + return c.json( + stepError('CHANNEL_NOT_REGISTERED', `Channel ${channelId} is not registered`, 'NOT_RETRYABLE'), + 403, + ); + } + + // Defensive input parsing — accept both Apps-Script-style nested shape and + // a flat shape. Workspace HTTP-mode shape isn't fully documented; both are + // observed in the wild. + const messageId = extractInputScalar(body, 'message_id') ?? extractScalar(body, 'message_id'); + const subject = extractInputScalar(body, 'subject') ?? extractScalar(body, 'subject') ?? ''; + const from = extractInputScalar(body, 'from') ?? extractScalar(body, 'from') ?? ''; + const disputeType = + extractInputScalar(body, 'dispute_type') ?? extractScalar(body, 'dispute_type') ?? 'public'; + const classification = + extractInputScalar(body, 'classification') ?? extractScalar(body, 'classification') ?? ''; + const attachmentIds = extractInputList(body, 'attachment_ids') ?? extractList(body, 'attachment_ids') ?? []; + const driveFolder = extractInputScalar(body, 'drive_folder_url') ?? extractScalar(body, 'drive_folder_url'); + const sheetRow = extractInputScalar(body, 'sheet_row_url') ?? extractScalar(body, 'sheet_row_url'); + + if (!messageId) { + return c.json( + stepError('MISSING_INPUT', 'message_id is required', 'NOT_RETRYABLE'), + 400, + ); + } + + // Idempotency by Gmail message_id. + const idempotencyKey = c.req.header('Idempotency-Key') ?? `gmail-${messageId}`; + const sql = getDb(c.env); + const existing = await sql` + SELECT id, privilege, space, payload + FROM cc_intents + WHERE payload->'source'->>'message_id' = ${messageId} + AND intent_type = 'roux_ingest' + LIMIT 1 + `; + if (existing[0]) { + const row = existing[0] as { id: string; privilege: string; space: string; payload: Record }; + return c.json( + stepSuccess({ + intent_id: row.id, + privilege: row.privilege, + space: row.space, + gate_outcome: (row.payload?.gate_outcome as string) ?? 'unknown', + content_hashes: ((row.payload?.content_hashes as string[]) ?? []), + idempotent_hit: true, + idempotency_key: idempotencyKey, + triage_url: `https://command.chitty.cc/triage/${row.id}`, + }), + ); + } + + // Roux derivation — combines dispute_type and classification. + const roux = deriveRouxFromType(classification || disputeType); + const gateOutcome = + roux.privilege === 'privileged' || roux.privilege === 'pii' || roux.space === 'legalink' + ? 'suppressed' + : 'mirrored'; + + // Create the goal/plan/intent chain. The intent is the durable artifact. + const ownerChittyId = wsCtx.user_email; // user email is acceptable as owner anchor for now + let intentId: string; + try { + const goal = await createGoal(c.env, { + ownerChittyId, + title: `roux_ingest: ${subject || messageId}`, + description: `Workspace Studio ingest from ${from}`, + priority: 5, + metadata: { source: 'workspace_studio', channel_id: channel.channel_id }, + }); + const plan = await createPlan(c.env, { + goalId: goal.id, + title: `Ingest Gmail message ${messageId}`, + authoredBy: 'workspace-studio', + }); + const intent = await createIntent(c.env, { + planId: plan.id, + goalId: goal.id, + intentType: 'roux_ingest', + targetChannel: channel.channel_id, + privilege: roux.privilege, + space: roux.space, + payload: { + source: { + channel: 'gmail', + message_id: messageId, + subject, + from, + }, + classification, + dispute_type: disputeType, + attachment_ids: attachmentIds, + drive_folder_url: driveFolder ?? null, + sheet_row_url: sheetRow ?? null, + gate_outcome: gateOutcome, + content_hashes: [] as string[], + }, + metadata: { + user_email: wsCtx.user_email, + idempotency_key: idempotencyKey, + }, + }); + intentId = intent.id; + } catch (err) { + return c.json( + stepError( + 'INTENT_CREATE_FAILED', + `createIntent failed: ${err instanceof Error ? err.message : String(err)}`, + 'RETRYABLE', + ), + 500, + ); + } + + // Fan-out — fire-and-forget via waitUntil so we stay under the 30s ceiling. + const ctx = c.executionCtx; + if (ctx && typeof ctx.waitUntil === 'function') { + for (const attId of attachmentIds) { + ctx.waitUntil(ingestAttachment(c.env, intentId, attId, wsCtx.user_oauth_token)); + } + ctx.waitUntil( + recordCustodyIfPrivileged(c.env, intentId, roux, { + message_id: messageId, + user_email: wsCtx.user_email, + }), + ); + ctx.waitUntil( + classifySecondPass(c.env, intentId, { + title: subject || messageId, + dispute_type: disputeType, + description: classification, + }), + ); + } + + return c.json( + stepSuccess({ + intent_id: intentId, + privilege: roux.privilege, + space: roux.space, + gate_outcome: gateOutcome, + content_hashes: [] as string[], + idempotent_hit: false, + idempotency_key: idempotencyKey, + triage_url: `https://command.chitty.cc/triage/${intentId}`, + drive_folder_url: driveFolder ?? null, + sheet_row_url: sheetRow ?? null, + }), + ); +}); + +// ── Async fan-out helpers ──────────────────────────────────────────────── + +async function ingestAttachment( + env: Env, + intentId: string, + attachmentId: string, + userOAuthToken: string | null, +): Promise { + try { + if (!env.SVC_STORAGE) return; + const res = await env.SVC_STORAGE.fetch('https://storage.internal/ingest', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Source-Service': 'chittycommand', + 'X-Intent-Id': intentId, + }, + body: JSON.stringify({ + source: 'gmail', + attachment_id: attachmentId, + user_oauth_token: userOAuthToken, + intent_id: intentId, + }), + }); + if (!res.ok) { + console.error(`[ws-studio] storage_ingest failed for ${attachmentId}: HTTP ${res.status}`); + } + } catch (err) { + console.error(`[ws-studio] storage_ingest exception for ${attachmentId}:`, err); + } +} + +async function recordCustodyIfPrivileged( + env: Env, + intentId: string, + roux: { privilege: string; space: string }, + ctx: { message_id: string; user_email: string }, +): Promise { + if (roux.privilege !== 'privileged' && roux.space !== 'legalink') return; + try { + const ev = evidenceClient(env); + if (!ev) return; + await ev.addCustodyEntry(intentId, { + action: 'ingested_from_gmail', + performedBy: ctx.user_email, + location: 'workspace-studio-roux-ingest', + notes: `gmail message_id=${ctx.message_id}`, + }); + } catch (err) { + console.error('[ws-studio] addCustodyEntry failed:', err); + } +} + +async function classifySecondPass( + env: Env, + intentId: string, + payload: { title: string; dispute_type: string; description: string }, +): Promise { + try { + const rc = routerClient(env); + if (!rc) return; + await rc.classifyDispute({ + entity_id: intentId, + entity_type: 'event', + title: payload.title, + dispute_type: payload.dispute_type, + description: payload.description, + }); + } catch (err) { + console.error('[ws-studio] classifyDispute second-pass failed:', err); + } +} + +// ── Output shape helpers ───────────────────────────────────────────────── + +interface StepSuccessOutputs { + intent_id: string; + privilege: string; + space: string; + gate_outcome: string; + content_hashes: string[]; + idempotent_hit: boolean; + idempotency_key: string; + triage_url: string; + drive_folder_url?: string | null; + sheet_row_url?: string | null; +} + +function stepSuccess(outputs: StepSuccessOutputs) { + // Workspace Studio expects an output object plus a log/notification block. + // We surface chip-style links via notifications text (HTTP mode has no + // TextFormatChip — we inline the URLs and Workspace's HTML renderer + // autolinks them). + const links: string[] = [`triage: ${outputs.triage_url}`]; + if (outputs.drive_folder_url) links.push(`drive: ${outputs.drive_folder_url}`); + if (outputs.sheet_row_url) links.push(`sheet: ${outputs.sheet_row_url}`); + const logText = + `intent_id=${outputs.intent_id} privilege=${outputs.privilege} space=${outputs.space} ` + + `gate=${outputs.gate_outcome} idempotent=${outputs.idempotent_hit ? 'yes' : 'no'}\n` + + links.join('\n'); + return { + status: 'SUCCESS', + outputs, + renderActions: { + action: { + notifications: [{ text: `Roux ingest ok: ${outputs.intent_id}` }], + }, + }, + log: logText, + }; +} + +function stepError( + code: string, + message: string, + retry: 'RETRYABLE' | 'NOT_RETRYABLE', +) { + return { + status: 'ACTIONABLE', + retry, + error: { code, message }, + renderActions: { + action: { + notifications: [{ text: `Roux ingest ${code}: ${message}` }], + }, + }, + log: `error ${code}: ${message}`, + }; +} + +// ── Defensive input extractors ─────────────────────────────────────────── + +function extractScalar(body: Record, key: string): string | null { + const v = body[key]; + if (typeof v === 'string') return v; + return null; +} + +function extractList(body: Record, key: string): string[] | null { + const v = body[key]; + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string'); + return null; +} + +function extractInputScalar(body: Record, key: string): string | null { + const workflow = ((body.event as Record)?.workflow ?? + (body as Record).workflow) as Record | undefined; + const action = (workflow?.actionInvocation as Record) ?? undefined; + const inputs = (action?.inputs as Record) ?? undefined; + const slot = inputs?.[key] as Record | undefined; + if (!slot) return null; + const sv = slot.stringValues as unknown; + if (Array.isArray(sv) && typeof sv[0] === 'string') return sv[0]; + if (typeof slot.value === 'string') return slot.value; + return null; +} + +function extractInputList(body: Record, key: string): string[] | null { + const workflow = ((body.event as Record)?.workflow ?? + (body as Record).workflow) as Record | undefined; + const action = (workflow?.actionInvocation as Record) ?? undefined; + const inputs = (action?.inputs as Record) ?? undefined; + const slot = inputs?.[key] as Record | undefined; + if (!slot) return null; + const sv = slot.stringValues as unknown; + if (Array.isArray(sv)) return sv.filter((x): x is string => typeof x === 'string'); + return null; +} diff --git a/tests/routes/workspace-studio-ingest.spec.ts b/tests/routes/workspace-studio-ingest.spec.ts new file mode 100644 index 0000000..08d0a70 --- /dev/null +++ b/tests/routes/workspace-studio-ingest.spec.ts @@ -0,0 +1,363 @@ +/** + * Integration tests for /workspace/studio/roux-ingest. + * + * Real-deps philosophy (per CLAUDE.md no-mocks rule): + * - JWT: real RS256 keypair generated via jose; JWKS served from a local + * http server; verifier configured via GCP_JWKS_URL env override. + * No global fetch mock — `jose` actually calls the JWKS URL. + * - DB: real Neon. Skipped without DATABASE_URL (mirrors the established + * pattern in tests/meta/intent-lifecycle.spec.ts and + * tests/routes/triage-roux.spec.ts — there is no neon-branch + * autoprovision helper in this repo yet, and the task explicitly + * forbids mocks). + * - Storage / Router / Evidence: bindings are absent in the test env so + * the route's async fan-out short-circuits. The intent row itself + * is the contract surface we assert against. + * + * @canon: chittycanon://core/services/chittycommand/workspace-studio + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { generateKeyPair, exportJWK, SignJWT } from 'jose'; +import { createServer, type Server } from 'node:http'; +import { neon } from '@neondatabase/serverless'; +import { + verifyWorkspaceSystemIdToken, + verifyWorkspaceUserIdToken, + WorkspaceJWTError, +} from '../../src/lib/workspace-jwt'; +import { workspaceStudioRoutes } from '../../src/routes/workspace-studio'; +import { Hono } from 'hono'; +import type { Env } from '../../src/index'; + +const DATABASE_URL = process.env.DATABASE_URL; +const SKIP_DB = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1'; +const TEST_TAG = `ws-studio-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +const SA_EMAIL = 'chittyclaw@chittyops.iam.gserviceaccount.com'; +const CLIENT_ID = '443939537625-a0un9jpol6gi53h7t53kbn4jic0o3c0m.apps.googleusercontent.com'; + +interface TestSigner { + privateKey: CryptoKey; + publicJwk: Record; + kid: string; + jwksUrl: string; + server: Server; +} + +async function startJwksServer(): Promise { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }); + const jwk = await exportJWK(publicKey); + const kid = `test-${Date.now()}`; + const publicJwk = { ...jwk, kid, alg: 'RS256', use: 'sig' }; + + const server = createServer((req, res) => { + if (req.url === '/certs') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ keys: [publicJwk] })); + } else { + res.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address(); + if (typeof addr === 'string' || !addr) throw new Error('JWKS server address missing'); + const jwksUrl = `http://127.0.0.1:${addr.port}/certs`; + return { privateKey, publicJwk, kid, jwksUrl, server }; +} + +async function signTestToken( + signer: TestSigner, + claims: Record, +): Promise { + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: signer.kid }) + .setIssuedAt() + .setExpirationTime('5m') + .setIssuer('https://accounts.google.com') + .sign(signer.privateKey); +} + +function makeKv(): KVNamespace { + const store = new Map(); + return { + get: (async (key: string, opts?: { type?: string }) => { + const v = store.get(key); + if (v === undefined) return null; + if (opts && opts.type === 'json') return JSON.parse(v); + return v; + }) as KVNamespace['get'], + put: (async (key: string, value: string) => { + store.set(key, value); + }) as KVNamespace['put'], + delete: (async (key: string) => { store.delete(key); }) as KVNamespace['delete'], + list: (async () => ({ keys: [], list_complete: true, cacheStatus: null })) as unknown as KVNamespace['list'], + getWithMetadata: (async () => ({ value: null, metadata: null, cacheStatus: null })) as unknown as KVNamespace['getWithMetadata'], + } as unknown as KVNamespace; +} + +let signer: TestSigner; +let baseEnv: Pick & { + CHITTYROUX_GCP_SA_EMAIL: string; + CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID: string; + GCP_JWKS_URL: string; +}; + +beforeAll(async () => { + signer = await startJwksServer(); + baseEnv = { + COMMAND_KV: makeKv(), + CHITTYROUX_GCP_SA_EMAIL: SA_EMAIL, + CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID: CLIENT_ID, + GCP_JWKS_URL: signer.jwksUrl, + }; + if (DATABASE_URL) { + const sql = neon(DATABASE_URL); + await sql`DELETE FROM cc_goals WHERE title LIKE ${TEST_TAG + '%'}`; + } +}); + +afterAll(async () => { + if (signer) await new Promise((r) => signer.server.close(() => r())); + if (DATABASE_URL) { + const sql = neon(DATABASE_URL); + await sql`DELETE FROM cc_goals WHERE title LIKE ${TEST_TAG + '%'}`; + } +}); + +// ── JWT verification (no DB needed) ──────────────────────────────────── + +describe('workspace-jwt verification', () => { + it('verifies a well-formed systemIdToken with correct SA + audience', async () => { + const token = await signTestToken(signer, { + sub: 'system-1', + email: SA_EMAIL, + aud: CLIENT_ID, + }); + const claims = await verifyWorkspaceSystemIdToken(token, baseEnv); + expect(claims.email).toBe(SA_EMAIL); + expect(claims.aud).toBe(CLIENT_ID); + }); + + it('rejects a systemIdToken with wrong SA email', async () => { + const token = await signTestToken(signer, { + sub: 'system-1', + email: 'attacker@evil.iam.gserviceaccount.com', + aud: CLIENT_ID, + }); + await expect(verifyWorkspaceSystemIdToken(token, baseEnv)).rejects.toBeInstanceOf( + WorkspaceJWTError, + ); + }); + + it('rejects a token with wrong audience', async () => { + const token = await signTestToken(signer, { + sub: 'system-1', + email: SA_EMAIL, + aud: 'someone-elses-client-id', + }); + await expect(verifyWorkspaceSystemIdToken(token, baseEnv)).rejects.toBeInstanceOf( + WorkspaceJWTError, + ); + }); + + it('verifies userIdToken and extracts user email', async () => { + const token = await signTestToken(signer, { + sub: 'user-42', + email: 'nick@nevershitty.com', + aud: CLIENT_ID, + }); + const claims = await verifyWorkspaceUserIdToken(token, baseEnv); + expect(claims.email).toBe('nick@nevershitty.com'); + }); +}); + +// ── Route integration (real Neon) ────────────────────────────────────── + +describe.skipIf(SKIP_DB)('workspace-studio route (real Neon)', () => { + const channelId = 'chitty:channel:workspace-studio-gmail'; + const REGISTERED_CHANNELS_JSON = JSON.stringify({ + [channelId]: { + channel_id: channelId, + chitty_id: channelId, + platform: 'google_workspace', + capabilities: ['gmail.ingest'], + status: 'active', + }, + }); + + async function buildAuthedBody(opts: { + messageId: string; + subject?: string; + disputeType?: string; + classification?: string; + flatShape?: boolean; + }) { + const sysTok = await signTestToken(signer, { + sub: 'sys-1', + email: SA_EMAIL, + aud: CLIENT_ID, + }); + const userTok = await signTestToken(signer, { + sub: 'user-1', + email: 'nick@nevershitty.com', + aud: CLIENT_ID, + }); + const inputs = opts.flatShape + ? { + message_id: opts.messageId, + subject: opts.subject ?? `${TEST_TAG}-${opts.messageId}`, + dispute_type: opts.disputeType ?? 'public', + classification: opts.classification ?? '', + } + : { + event: { + workflow: { + actionInvocation: { + inputs: { + message_id: { stringValues: [opts.messageId] }, + subject: { stringValues: [opts.subject ?? `${TEST_TAG}-${opts.messageId}`] }, + dispute_type: { stringValues: [opts.disputeType ?? 'public'] }, + classification: { stringValues: [opts.classification ?? ''] }, + }, + }, + }, + }, + }; + return { + authorizationEventObject: { + systemIdToken: sysTok, + userIdToken: userTok, + userOAuthToken: 'oauth-test', + }, + channel_id: channelId, + ...inputs, + }; + } + + function buildApp() { + const app = new Hono<{ Bindings: Env }>(); + app.route('/workspace/studio/roux-ingest', workspaceStudioRoutes); + return app; + } + + function buildEnv(): Env { + return { + ...baseEnv, + DATABASE_URL, + REGISTERED_CHANNELS_JSON, + ENVIRONMENT: 'test', + } as unknown as Env; + } + + it('creates an intent with derived Roux from classification (Apps-Script shape)', async () => { + const app = buildApp(); + const body = await buildAuthedBody({ + messageId: `${TEST_TAG}-msg-a`, + classification: 'public', + disputeType: 'public', + }); + const res = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + buildEnv(), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { status: string; outputs: Record }; + expect(json.status).toBe('SUCCESS'); + expect(json.outputs.privilege).toBe('public'); + expect(json.outputs.space).toBe('business'); + expect(json.outputs.gate_outcome).toBe('mirrored'); + expect(typeof json.outputs.intent_id).toBe('string'); + + const sql = neon(DATABASE_URL!); + const rows = await sql`SELECT privilege, space FROM cc_intents WHERE id = ${json.outputs.intent_id as string}`; + expect(rows[0]?.privilege).toBe('public'); + expect(rows[0]?.space).toBe('business'); + }); + + it('is idempotent by Gmail message_id (second call returns same intent_id)', async () => { + const app = buildApp(); + const messageId = `${TEST_TAG}-msg-idem`; + const first = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(await buildAuthedBody({ messageId })), + }), + buildEnv(), + ); + const firstJson = (await first.json()) as { outputs: { intent_id: string; idempotent_hit: boolean } }; + expect(firstJson.outputs.idempotent_hit).toBe(false); + + const second = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(await buildAuthedBody({ messageId })), + }), + buildEnv(), + ); + const secondJson = (await second.json()) as { outputs: { intent_id: string; idempotent_hit: boolean } }; + expect(secondJson.outputs.idempotent_hit).toBe(true); + expect(secondJson.outputs.intent_id).toBe(firstJson.outputs.intent_id); + }); + + it('suppresses Notion mirror when classification is privileged/legal', async () => { + const app = buildApp(); + const body = await buildAuthedBody({ + messageId: `${TEST_TAG}-msg-legal`, + classification: 'legal', + disputeType: 'legal', + }); + const res = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + buildEnv(), + ); + const json = (await res.json()) as { outputs: { privilege: string; space: string; gate_outcome: string } }; + expect(json.outputs.privilege).toBe('privileged'); + expect(json.outputs.space).toBe('legalink'); + expect(json.outputs.gate_outcome).toBe('suppressed'); + }); + + it('accepts the flat input shape too (defensive parsing)', async () => { + const app = buildApp(); + const body = await buildAuthedBody({ + messageId: `${TEST_TAG}-msg-flat`, + flatShape: true, + }); + const res = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + buildEnv(), + ); + const json = (await res.json()) as { status: string; outputs: { intent_id: string } }; + expect(json.status).toBe('SUCCESS'); + expect(typeof json.outputs.intent_id).toBe('string'); + }); + + it('rejects request when channel is not in REGISTERED_CHANNELS_JSON', async () => { + const app = buildApp(); + const body = await buildAuthedBody({ messageId: `${TEST_TAG}-msg-bad-ch` }); + body.channel_id = 'chitty:channel:unknown'; + const res = await app.fetch( + new Request('http://test/workspace/studio/roux-ingest/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + buildEnv(), + ); + expect(res.status).toBe(403); + }); +}); From 6398709f3e0e41d78a7a910628caff92c288e08e Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:28:20 +0000 Subject: [PATCH 2/9] fix(workspace-jwt): verify systemIdToken audience against endpoint URL (P1) Per Google's Workspace HTTP add-on docs (https://developers.google.com/workspace/add-ons/guides/alternate-runtimes#validate_requests), the systemIdToken `aud` claim is the full endpoint URL Google was configured to call, not the OAuth client_id. The OAuth client_id audience is used only for userIdToken. Reusing CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID for systemIdToken would have rejected every real Workspace invocation with TOKEN_INVALID before the handler ran. - verifyWorkspaceSystemIdToken now requires `requestUrl` and pins aud to it - workspaceAuth middleware passes c.req.url - userIdToken aud remains pinned to CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID - Tests updated: systemIdToken signs with endpoint URL, new regressions reject (a) systemIdToken using CLIENT_ID and (b) userIdToken using URL Co-Authored-By: Claude Opus 4.7 --- src/lib/workspace-jwt.ts | 18 +++++-- src/middleware/workspace-auth.ts | 6 ++- tests/routes/workspace-studio-ingest.spec.ts | 57 +++++++++++++++----- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/lib/workspace-jwt.ts b/src/lib/workspace-jwt.ts index 80a85dc..f378ebe 100644 --- a/src/lib/workspace-jwt.ts +++ b/src/lib/workspace-jwt.ts @@ -145,20 +145,28 @@ export interface WorkspaceTokenClaims { } /** - * Verify Google's `systemIdToken`: the email claim MUST match the configured - * marketplace service account. + * Verify Google's `systemIdToken`: per the Workspace HTTP add-on docs + * (https://developers.google.com/workspace/add-ons/guides/alternate-runtimes#validate_requests), + * the `aud` claim is the **full endpoint URL** Google invoked (not the OAuth + * client ID — that audience is used only for `userIdToken`). The `email` + * claim MUST match the configured marketplace service account. + * + * @param token The systemIdToken from authorizationEventObject. + * @param env Worker env with COMMAND_KV + SA pinning config. + * @param requestUrl The canonical endpoint URL Google was configured to call + * (i.e. `c.req.url` or a manifest-derived equivalent). */ export async function verifyWorkspaceSystemIdToken( token: string, env: WorkspaceEnv, + requestUrl: string, ): Promise { - const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID; 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'); + if (!requestUrl) throw new WorkspaceJWTError('CONFIG_MISSING', 'requestUrl required for systemIdToken verification'); const jwks = await getJWKS(env); - const payload = await verifyWithJWKS(token, jwks, expectedAud); + const payload = await verifyWithJWKS(token, jwks, requestUrl); requireIssuer(payload); const email = typeof payload.email === 'string' ? payload.email : ''; diff --git a/src/middleware/workspace-auth.ts b/src/middleware/workspace-auth.ts index 51e7053..7e85279 100644 --- a/src/middleware/workspace-auth.ts +++ b/src/middleware/workspace-auth.ts @@ -78,7 +78,11 @@ export function workspaceAuth(): MiddlewareHandler<{ } try { - const sysClaims = await verifyWorkspaceSystemIdToken(systemIdToken, c.env); + // systemIdToken's `aud` is the endpoint URL Google was configured to + // invoke (HTTP add-on contract). userIdToken's `aud` is the OAuth + // client ID. Pass c.req.url so the verifier can pin to the canonical + // endpoint Google called. + const sysClaims = await verifyWorkspaceSystemIdToken(systemIdToken, c.env, c.req.url); const userClaims = await verifyWorkspaceUserIdToken(userIdToken, c.env); c.set('workspaceContext', { diff --git a/tests/routes/workspace-studio-ingest.spec.ts b/tests/routes/workspace-studio-ingest.spec.ts index 08d0a70..884e4a3 100644 --- a/tests/routes/workspace-studio-ingest.spec.ts +++ b/tests/routes/workspace-studio-ingest.spec.ts @@ -127,41 +127,58 @@ afterAll(async () => { // ── JWT verification (no DB needed) ──────────────────────────────────── +const ENDPOINT_URL = 'https://command.chitty.cc/workspace/studio/roux-ingest/execute'; + describe('workspace-jwt verification', () => { - it('verifies a well-formed systemIdToken with correct SA + audience', async () => { + it('verifies a well-formed systemIdToken with correct SA + endpoint URL audience', async () => { + // systemIdToken: aud is the endpoint URL Google called (HTTP add-on contract). const token = await signTestToken(signer, { sub: 'system-1', email: SA_EMAIL, - aud: CLIENT_ID, + aud: ENDPOINT_URL, }); - const claims = await verifyWorkspaceSystemIdToken(token, baseEnv); + const claims = await verifyWorkspaceSystemIdToken(token, baseEnv, ENDPOINT_URL); expect(claims.email).toBe(SA_EMAIL); - expect(claims.aud).toBe(CLIENT_ID); + expect(claims.aud).toBe(ENDPOINT_URL); }); it('rejects a systemIdToken with wrong SA email', async () => { const token = await signTestToken(signer, { sub: 'system-1', email: 'attacker@evil.iam.gserviceaccount.com', + aud: ENDPOINT_URL, + }); + await expect( + verifyWorkspaceSystemIdToken(token, baseEnv, ENDPOINT_URL), + ).rejects.toBeInstanceOf(WorkspaceJWTError); + }); + + it('rejects a systemIdToken whose aud is the OAuth client_id (wrong audience for system token)', async () => { + // Regression: prior implementation incorrectly pinned systemIdToken to + // CLIENT_ID. Real Google tokens pin to the endpoint URL. + const token = await signTestToken(signer, { + sub: 'system-1', + email: SA_EMAIL, aud: CLIENT_ID, }); - await expect(verifyWorkspaceSystemIdToken(token, baseEnv)).rejects.toBeInstanceOf( - WorkspaceJWTError, - ); + await expect( + verifyWorkspaceSystemIdToken(token, baseEnv, ENDPOINT_URL), + ).rejects.toBeInstanceOf(WorkspaceJWTError); }); - it('rejects a token with wrong audience', async () => { + it('rejects a systemIdToken whose aud is a different endpoint URL', async () => { const token = await signTestToken(signer, { sub: 'system-1', email: SA_EMAIL, - aud: 'someone-elses-client-id', + aud: 'https://command.chitty.cc/somewhere-else', }); - await expect(verifyWorkspaceSystemIdToken(token, baseEnv)).rejects.toBeInstanceOf( - WorkspaceJWTError, - ); + await expect( + verifyWorkspaceSystemIdToken(token, baseEnv, ENDPOINT_URL), + ).rejects.toBeInstanceOf(WorkspaceJWTError); }); - it('verifies userIdToken and extracts user email', async () => { + it('verifies userIdToken with OAuth client_id audience and extracts user email', async () => { + // userIdToken: aud is the OAuth client_id (unchanged). const token = await signTestToken(signer, { sub: 'user-42', email: 'nick@nevershitty.com', @@ -170,6 +187,17 @@ describe('workspace-jwt verification', () => { const claims = await verifyWorkspaceUserIdToken(token, baseEnv); expect(claims.email).toBe('nick@nevershitty.com'); }); + + it('rejects a userIdToken whose aud is the endpoint URL (wrong audience for user token)', async () => { + const token = await signTestToken(signer, { + sub: 'user-42', + email: 'nick@nevershitty.com', + aud: ENDPOINT_URL, + }); + await expect(verifyWorkspaceUserIdToken(token, baseEnv)).rejects.toBeInstanceOf( + WorkspaceJWTError, + ); + }); }); // ── Route integration (real Neon) ────────────────────────────────────── @@ -193,10 +221,11 @@ describe.skipIf(SKIP_DB)('workspace-studio route (real Neon)', () => { classification?: string; flatShape?: boolean; }) { + // systemIdToken aud = endpoint URL Google calls; matches c.req.url in handler. const sysTok = await signTestToken(signer, { sub: 'sys-1', email: SA_EMAIL, - aud: CLIENT_ID, + aud: 'http://test/workspace/studio/roux-ingest/execute', }); const userTok = await signTestToken(signer, { sub: 'user-1', From ab09e9e1f9d43f579c256d6099d3236d5e47465c Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:30:10 +0000 Subject: [PATCH 3/9] fix(workspace-studio): merge classification + dispute_type, take strictest (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior code `deriveRouxFromType(classification || disputeType)` ignored the caller's dispute_type whenever classification was non-empty. A Workspace flow that sent classification="public" but dispute_type="legal" was stored as public/business with gate_outcome=mirrored — leaking privileged content to the public Notion bucket. - New mergeRouxClassification(a, b) helper in dispute-sync.ts picks the more sensitive privilege AND space independently (public --- src/lib/dispute-sync.ts | 38 ++++++++++++++++++++++++++++++++-- src/routes/workspace-studio.ts | 12 ++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/lib/dispute-sync.ts b/src/lib/dispute-sync.ts index bf8976b..24a2db8 100644 --- a/src/lib/dispute-sync.ts +++ b/src/lib/dispute-sync.ts @@ -35,11 +35,45 @@ interface DisputeCore { } // @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING +export type RouxPrivilege = 'privileged' | 'pii' | 'hoa_evidentiary' | 'public'; +export type RouxSpace = 'business' | 'legalink'; + +// Strictness ordering — higher = more sensitive. Used by mergeRouxClassification +// to take the more sensitive of two derived roux candidates. +const PRIVILEGE_RANK: Record = { + public: 0, + hoa_evidentiary: 1, + pii: 2, + privileged: 3, +}; +const SPACE_RANK: Record = { + business: 0, + legalink: 1, +}; + +/** + * Pick the MORE sensitive of two Roux classifications. Privilege and space are + * compared independently — e.g. (public, legalink) merged with (privileged, + * business) yields (privileged, legalink). This prevents an upstream "public" + * classification from masking a caller-supplied "legal" dispute_type. + * + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + */ +export function mergeRouxClassification( + a: { privilege: RouxPrivilege; space: RouxSpace }, + b: { privilege: RouxPrivilege; space: RouxSpace }, +): { privilege: RouxPrivilege; space: RouxSpace } { + return { + privilege: PRIVILEGE_RANK[a.privilege] >= PRIVILEGE_RANK[b.privilege] ? a.privilege : b.privilege, + space: SPACE_RANK[a.space] >= SPACE_RANK[b.space] ? a.space : b.space, + }; +} + // Map a cc_disputes.dispute_type to default Roux (privilege, space). // Explicit caller-supplied values always override (Q1=(c) pass-through derive). export function deriveRouxFromType(disputeType: string): { - privilege: 'privileged' | 'pii' | 'hoa_evidentiary' | 'public'; - space: 'business' | 'legalink'; + privilege: RouxPrivilege; + space: RouxSpace; } { // Fail-safe routing: dispute_type is free-text from the API/UI. Any string // containing "legal" routes to privileged/legalink (prevents leakage of diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index 7e11e36..cad3d55 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -29,7 +29,7 @@ import { WORKSPACE_STUDIO_CHANNEL_ID, } from '../lib/channel-registry'; import { createIntent, createGoal, createPlan } from '../../meta/intent'; -import { deriveRouxFromType } from '../lib/dispute-sync'; +import { deriveRouxFromType, mergeRouxClassification } from '../lib/dispute-sync'; import { getDb } from '../lib/db'; import { evidenceClient, routerClient } from '../lib/integrations'; @@ -154,8 +154,14 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { ); } - // Roux derivation — combines dispute_type and classification. - const roux = deriveRouxFromType(classification || disputeType); + // Roux derivation — derive from BOTH classification and dispute_type, then + // take the MORE sensitive of the two. Prior code used `classification || + // disputeType` which let a "public" classification mask a privileged + // dispute_type (e.g. "legal"). Take strictest privilege AND strictest space + // independently — see mergeRouxClassification. + const rouxFromClassification = deriveRouxFromType(classification); + const rouxFromDisputeType = deriveRouxFromType(disputeType); + const roux = mergeRouxClassification(rouxFromClassification, rouxFromDisputeType); const gateOutcome = roux.privilege === 'privileged' || roux.privilege === 'pii' || roux.space === 'legalink' ? 'suppressed' From fa3674cae3bcf1c7fe3c3e2240bc67cfaa2597c6 Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:33:30 +0000 Subject: [PATCH 4/9] fix(workspace-studio): atomic idempotency via partial unique index (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior SELECT-then-INSERT was TOCTOU-racy: two concurrent Google retries of the same Gmail event could both pass the SELECT pre-check before either INSERT landed, producing duplicate roux_ingest intents and double-fanned side effects. - New migration 0017_roux_ingest_idempotency.sql: partial unique index on cc_intents ((payload->'source'->>'message_id')) WHERE intent_type='roux_ingest' AND ...->>'message_id' IS NOT NULL - New createRouxIngestIntentIdempotent in meta/intent.ts: INSERT ... ON CONFLICT (...) WHERE ... DO NOTHING RETURNING *, falls back to re-SELECT when conflict fires - Route uses the helper; loser of the race skips fanout (winner already dispatched it) - Sanitized createIntent error to drop err.message from client response Validated on disposable Neon branch (br-lingering-band-akqje5lm): created index, ran double-insert with ON CONFLICT — second insert returned empty result set (DO NOTHING fired), count remained 1. Co-Authored-By: Claude Opus 4.7 --- meta/intent.ts | 54 +++++++++++++++++++++ migrations/0017_roux_ingest_idempotency.sql | 19 ++++++++ src/routes/workspace-studio.ts | 31 +++++++++--- 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 migrations/0017_roux_ingest_idempotency.sql diff --git a/meta/intent.ts b/meta/intent.ts index ad440cc..17ea545 100644 --- a/meta/intent.ts +++ b/meta/intent.ts @@ -241,6 +241,60 @@ export async function createIntent(env: IntentEnv, input: CreateIntentInput): Pr return rowToIntent(rows[0]); } +/** + * Atomic create-or-fetch for roux_ingest intents keyed by Gmail message_id. + * + * Backed by the partial unique index `cc_intents_roux_ingest_message_id_uidx` + * (migration 0017). Two concurrent retries of the same Gmail event race on + * INSERT; the loser hits the unique violation and we re-SELECT to return the + * winner's row. Eliminates the TOCTOU window of SELECT-then-INSERT. + * + * @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING + */ +export async function createRouxIngestIntentIdempotent( + env: IntentEnv, + input: CreateIntentInput & { messageId: string }, +): Promise<{ intent: Intent; created: boolean }> { + const sql = getSql(env); + const initialStatus: IntentStatus = + input.sovereigntyAssessment?.decision === 'requires_human' + ? 'blocked_human' + : input.sovereigntyAssessment?.decision === 'blocked' + ? 'failed' + : 'pending'; + + const inserted = await sql` + INSERT INTO cc_intents + (plan_id, goal_id, intent_type, target_channel, payload, status, priority, + sovereignty_assessment, human_gate_reason, scheduled_for, privilege, space, metadata) + VALUES + (${input.planId}, ${input.goalId}, ${input.intentType}, + ${input.targetChannel ?? null}, ${JSON.stringify(input.payload)}::jsonb, + ${initialStatus}, ${input.priority ?? 5}, + ${input.sovereigntyAssessment ? JSON.stringify(input.sovereigntyAssessment) : null}::jsonb, + ${input.humanGateReason ?? null}, ${input.scheduledFor ?? null}, + ${input.privilege ?? 'public'}, ${input.space ?? 'business'}, + ${JSON.stringify(input.metadata ?? {})}::jsonb) + ON CONFLICT ((payload->'source'->>'message_id')) + WHERE intent_type = 'roux_ingest' + AND payload->'source'->>'message_id' IS NOT NULL + DO NOTHING + RETURNING *`; + if (inserted[0]) { + return { intent: rowToIntent(inserted[0]), created: true }; + } + // Conflict — re-fetch the winner. + const winner = await sql` + SELECT * FROM cc_intents + WHERE intent_type = 'roux_ingest' + AND payload->'source'->>'message_id' = ${input.messageId} + LIMIT 1`; + if (!winner[0]) { + throw new Error(`ON CONFLICT path with no winning row for message_id=${input.messageId}`); + } + return { intent: rowToIntent(winner[0]), created: false }; +} + export async function getIntent(env: IntentEnv, id: string): Promise { const sql = getSql(env); const rows = await sql`SELECT * FROM cc_intents WHERE id = ${id} LIMIT 1`; diff --git a/migrations/0017_roux_ingest_idempotency.sql b/migrations/0017_roux_ingest_idempotency.sql new file mode 100644 index 0000000..b834e45 --- /dev/null +++ b/migrations/0017_roux_ingest_idempotency.sql @@ -0,0 +1,19 @@ +-- 0017_roux_ingest_idempotency.sql +-- +-- Partial unique index on Gmail message_id for intent_type='roux_ingest'. +-- Backs the atomic INSERT ... ON CONFLICT DO NOTHING idempotency guard in +-- src/routes/workspace-studio.ts. Without this, two concurrent Google retries +-- of the same Gmail event can both pass a SELECT pre-check before either +-- INSERT lands, producing duplicate intents and double-fanned-out side +-- effects. +-- +-- Partial (WHERE intent_type = 'roux_ingest') so other intent_types that +-- happen to carry payload->source->>message_id (e.g. future SMS sources) are +-- not constrained. +-- +-- @canon: chittycanon://core/services/chittycommand/workspace-studio + +CREATE UNIQUE INDEX IF NOT EXISTS cc_intents_roux_ingest_message_id_uidx + ON cc_intents ((payload->'source'->>'message_id')) + WHERE intent_type = 'roux_ingest' + AND payload->'source'->>'message_id' IS NOT NULL; diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index cad3d55..93482dd 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -28,7 +28,7 @@ import { verifyRegisteredChannel, WORKSPACE_STUDIO_CHANNEL_ID, } from '../lib/channel-registry'; -import { createIntent, createGoal, createPlan } from '../../meta/intent'; +import { createGoal, createPlan, createRouxIngestIntentIdempotent } from '../../meta/intent'; import { deriveRouxFromType, mergeRouxClassification } from '../lib/dispute-sync'; import { getDb } from '../lib/db'; import { evidenceClient, routerClient } from '../lib/integrations'; @@ -129,6 +129,14 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { } // Idempotency by Gmail message_id. + // + // Pre-check via SELECT is a TOCTOU race — two concurrent Google retries can + // both pass before either INSERT lands. The atomic guard is the partial + // unique index `cc_intents_roux_ingest_message_id_uidx` (migration 0017) + // combined with `INSERT ... ON CONFLICT DO NOTHING` on the createIntent + // call below. The SELECT here is a fast-path for the common case (sequential + // retry) — if it hits, we return the existing row without re-running + // createGoal/createPlan/createIntent at all. const idempotencyKey = c.req.header('Idempotency-Key') ?? `gmail-${messageId}`; const sql = getDb(c.env); const existing = await sql` @@ -168,8 +176,14 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { : 'mirrored'; // Create the goal/plan/intent chain. The intent is the durable artifact. + // Intent creation is idempotent on Gmail message_id (atomic ON CONFLICT + // against the partial unique index in migration 0017). If two concurrent + // Google retries reach this point, exactly one wins the INSERT; the loser + // re-SELECTs and gets the winner's intent_id. The goal/plan rows from the + // losing race are orphaned but harmless. const ownerChittyId = wsCtx.user_email; // user email is acceptable as owner anchor for now let intentId: string; + let idempotentHitFromRace = false; try { const goal = await createGoal(c.env, { ownerChittyId, @@ -183,13 +197,14 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { title: `Ingest Gmail message ${messageId}`, authoredBy: 'workspace-studio', }); - const intent = await createIntent(c.env, { + const result = await createRouxIngestIntentIdempotent(c.env, { planId: plan.id, goalId: goal.id, intentType: 'roux_ingest', targetChannel: channel.channel_id, privilege: roux.privilege, space: roux.space, + messageId, payload: { source: { channel: 'gmail', @@ -210,12 +225,14 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { idempotency_key: idempotencyKey, }, }); - intentId = intent.id; + intentId = result.intent.id; + idempotentHitFromRace = !result.created; } catch (err) { + console.error('[ws-studio] createIntent failed:', err); return c.json( stepError( 'INTENT_CREATE_FAILED', - `createIntent failed: ${err instanceof Error ? err.message : String(err)}`, + 'Failed to create triage intent. Please retry.', 'RETRYABLE', ), 500, @@ -223,8 +240,10 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { } // Fan-out — fire-and-forget via waitUntil so we stay under the 30s ceiling. + // Skip when we lost the idempotency race; the winner already kicked off + // fanout. const ctx = c.executionCtx; - if (ctx && typeof ctx.waitUntil === 'function') { + if (ctx && typeof ctx.waitUntil === 'function' && !idempotentHitFromRace) { for (const attId of attachmentIds) { ctx.waitUntil(ingestAttachment(c.env, intentId, attId, wsCtx.user_oauth_token)); } @@ -250,7 +269,7 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { space: roux.space, gate_outcome: gateOutcome, content_hashes: [] as string[], - idempotent_hit: false, + idempotent_hit: idempotentHitFromRace, idempotency_key: idempotencyKey, triage_url: `https://command.chitty.cc/triage/${intentId}`, drive_folder_url: driveFolder ?? null, From 4c458d68aa77115c5340df51175a2b663dba70dd Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:34:08 +0000 Subject: [PATCH 5/9] fix(workspace-studio): forward gmail_message_id to storage ingest (P2) Gmail's users.messages.attachments.get endpoint requires BOTH messageId and attachment id. Prior fanout only passed attachment_id + OAuth token, so chittystorage couldn't reliably hit the Gmail API path for attachments not already mirrored to Drive. - ingestAttachment now takes gmailMessageId and includes it in the storage_ingest payload as `gmail_message_id` Co-Authored-By: Claude Opus 4.7 --- src/routes/workspace-studio.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index 93482dd..ac5cd44 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -245,7 +245,9 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { const ctx = c.executionCtx; if (ctx && typeof ctx.waitUntil === 'function' && !idempotentHitFromRace) { for (const attId of attachmentIds) { - ctx.waitUntil(ingestAttachment(c.env, intentId, attId, wsCtx.user_oauth_token)); + ctx.waitUntil( + ingestAttachment(c.env, intentId, attId, messageId, wsCtx.user_oauth_token), + ); } ctx.waitUntil( recordCustodyIfPrivileged(c.env, intentId, roux, { @@ -284,10 +286,16 @@ async function ingestAttachment( env: Env, intentId: string, attachmentId: string, + gmailMessageId: string, userOAuthToken: string | null, ): Promise { try { if (!env.SVC_STORAGE) return; + // Gmail attachments API: users.messages.attachments.get requires BOTH + // messageId and attachment id + // (https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages.attachments/get). + // Forward gmail_message_id alongside attachment_id so chittystorage can + // hit the Gmail API path when the file isn't already in Drive. const res = await env.SVC_STORAGE.fetch('https://storage.internal/ingest', { method: 'POST', headers: { @@ -298,6 +306,7 @@ async function ingestAttachment( body: JSON.stringify({ source: 'gmail', attachment_id: attachmentId, + gmail_message_id: gmailMessageId, user_oauth_token: userOAuthToken, intent_id: intentId, }), From 7b0b5bec90bac528070d13fa76007dd56916dfda Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:35:06 +0000 Subject: [PATCH 6/9] fix(workspace-studio): wrap outputs in hostAppAction per Studio contract (P2) Workspace Studio's onExecuteFunction reads step outputs from hostAppAction.workflowAction.returnOutputVariablesAction.outputVariables[]. Returning bare `outputs` left downstream Studio steps with no variables to reference (https://developers.google.com/workspace/add-ons/studio/output-variables). - stepSuccess now wraps each output as {name, value} under returnOutputVariablesAction.outputVariables - stepError uses returnElementErrorAction with errorActionability / errorRetryability / errorLog / errorMessage / errorCode - Bare `outputs`/`error` retained as non-breaking shim for the existing integration tests that read them directly Co-Authored-By: Claude Opus 4.7 --- src/routes/workspace-studio.ts | 39 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index ac5cd44..44bf06d 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -375,11 +375,16 @@ interface StepSuccessOutputs { sheet_row_url?: string | null; } +// Workspace Studio output contract: +// https://developers.google.com/workspace/add-ons/studio/output-variables +// The execute step must wrap outputs in +// hostAppAction.workflowAction.returnOutputVariablesAction.outputVariables[] +// for downstream Studio steps to see them. Errors mirror the matrix with +// returnElementErrorAction. function stepSuccess(outputs: StepSuccessOutputs) { - // Workspace Studio expects an output object plus a log/notification block. - // We surface chip-style links via notifications text (HTTP mode has no - // TextFormatChip — we inline the URLs and Workspace's HTML renderer - // autolinks them). + const outputVariables = Object.entries(outputs) + .filter(([, v]) => v !== undefined) + .map(([name, value]) => ({ name, value })); const links: string[] = [`triage: ${outputs.triage_url}`]; if (outputs.drive_folder_url) links.push(`drive: ${outputs.drive_folder_url}`); if (outputs.sheet_row_url) links.push(`sheet: ${outputs.sheet_row_url}`); @@ -388,8 +393,19 @@ function stepSuccess(outputs: StepSuccessOutputs) { `gate=${outputs.gate_outcome} idempotent=${outputs.idempotent_hit ? 'yes' : 'no'}\n` + links.join('\n'); return { - status: 'SUCCESS', + hostAppAction: { + workflowAction: { + returnOutputVariablesAction: { + outputVariables, + log: { text: logText }, + }, + }, + }, + // Keep the bare `outputs` field as a non-breaking shim for any internal + // consumer / test that already reads it. Studio itself reads from + // hostAppAction.workflowAction.returnOutputVariablesAction. outputs, + status: 'SUCCESS', renderActions: { action: { notifications: [{ text: `Roux ingest ok: ${outputs.intent_id}` }], @@ -404,7 +420,20 @@ function stepError( message: string, retry: 'RETRYABLE' | 'NOT_RETRYABLE', ) { + // Workspace Studio error matrix: returnElementErrorAction with explicit + // actionability + retryability + an error log entry. return { + hostAppAction: { + workflowAction: { + returnElementErrorAction: { + errorActionability: 'ACTIONABLE', + errorRetryability: retry, + errorLog: { text: `error ${code}: ${message}` }, + errorMessage: { text: message }, + errorCode: code, + }, + }, + }, status: 'ACTIONABLE', retry, error: { code, message }, From c198c27da261b6140db262b2c64fa565bb5df0ae Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:36:00 +0000 Subject: [PATCH 7/9] fix(channel-registry): enforce required capabilities on channel verify (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior verifyRegisteredChannel only checked registration + active status, so ANY active channel in REGISTERED_CHANNELS_JSON could be supplied as channel_id for any operation — including ones that lacked the required capability. A flow could submit an active-but-non-gmail channel and still create a roux_ingest intent targeting it. - verifyRegisteredChannel takes requiredCapabilities: string[] and rejects channels missing any one - workspace-studio /execute asserts ['gmail.ingest'], with separate CHANNEL_NOT_REGISTERED vs CHANNEL_MISSING_CAPABILITY error codes so operators can diagnose which gate fired Co-Authored-By: Claude Opus 4.7 --- src/lib/channel-registry.ts | 11 +++++++++++ src/routes/workspace-studio.ts | 23 ++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/lib/channel-registry.ts b/src/lib/channel-registry.ts index ce653bf..1a30d67 100644 --- a/src/lib/channel-registry.ts +++ b/src/lib/channel-registry.ts @@ -49,6 +49,7 @@ export const WORKSPACE_STUDIO_CHANNEL_ID = 'chitty:channel:workspace-studio-gmai export async function verifyRegisteredChannel( channelId: string, env: ChannelRegistryEnv, + requiredCapabilities: string[] = [], ): Promise { if (!channelId) return null; @@ -64,5 +65,15 @@ export async function verifyRegisteredChannel( const meta = parsed[channelId]; if (!meta) return null; if (meta.status !== 'active') return null; + // Capability gate: the channel must declare every required capability. A + // registered+active channel that lacks the capability for THIS operation + // (e.g. an SMS channel asked to perform `gmail.ingest`) must be rejected + // so the capability manifest is actually enforced. + if (requiredCapabilities.length > 0) { + const caps = new Set(meta.capabilities ?? []); + for (const cap of requiredCapabilities) { + if (!caps.has(cap)) return null; + } + } return meta; } diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index 44bf06d..9153988 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -94,18 +94,35 @@ workspaceStudioRoutes.post('/execute', workspaceAuth(), async (c) => { const body = c.get('workspaceBody') as Record; const wsCtx = c.get('workspaceContext'); - // Channel registration check. + // Channel registration check + capability gate. roux_ingest from Workspace + // Studio requires the channel to declare gmail.ingest — otherwise the + // capability manifest is meaningless. We distinguish "not registered" + // (resolver returns null with no caps requested) from "lacks capability" + // (resolver returns null only with caps requested) so the operator can tell + // why a request was rejected. + const REQUIRED_CAPS = ['gmail.ingest']; const channelId = extractScalar(body, 'channel_id') ?? extractInputScalar(body, 'channel_id') ?? WORKSPACE_STUDIO_CHANNEL_ID; - const channel = await verifyRegisteredChannel(channelId, c.env); - if (!channel) { + const channelExists = await verifyRegisteredChannel(channelId, c.env); + if (!channelExists) { return c.json( stepError('CHANNEL_NOT_REGISTERED', `Channel ${channelId} is not registered`, 'NOT_RETRYABLE'), 403, ); } + const channel = await verifyRegisteredChannel(channelId, c.env, REQUIRED_CAPS); + if (!channel) { + return c.json( + stepError( + 'CHANNEL_MISSING_CAPABILITY', + `Channel ${channelId} lacks required capability: ${REQUIRED_CAPS.join(', ')}`, + 'NOT_RETRYABLE', + ), + 403, + ); + } // Defensive input parsing — accept both Apps-Script-style nested shape and // a flat shape. Workspace HTTP-mode shape isn't fully documented; both are From 47268293194595b8472977e6aa102d15ffab31c4 Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:36:31 +0000 Subject: [PATCH 8/9] fix(workspace-studio): return config card as bare Card proto (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace Studio's onConfigFunction expects the Card returned directly as the response body. The renderActions.action.navigations.pushCard wrapper used previously is for runtime cards in regular add-ons — Studio configuration cards explicitly disallow pushCard navigation (https://developers.google.com/workspace/add-ons/studio/configuration-cards#card_considerations_and_limitations). The settings panel was likely failing to render with the prior shape; return {sections:[...]} directly. Co-Authored-By: Claude Opus 4.7 --- src/routes/workspace-studio.ts | 67 ++++++++++++++++------------------ 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/src/routes/workspace-studio.ts b/src/routes/workspace-studio.ts index 9153988..6f15570 100644 --- a/src/routes/workspace-studio.ts +++ b/src/routes/workspace-studio.ts @@ -42,49 +42,44 @@ export const workspaceStudioRoutes = new Hono<{ // Returned to Workspace Studio when a workflow author opens the custom step // settings. Single-card limitation: no nav, no multi-step. workspaceStudioRoutes.post('/config', async (c) => { + // Workspace Studio onConfigFunction contract: return the Card proto as the + // BARE response body — NOT wrapped in renderActions/navigations/pushCard. + // Card navigation (pushCard) is explicitly unsupported for Studio + // configuration cards + // (https://developers.google.com/workspace/add-ons/studio/configuration-cards#card_considerations_and_limitations). + // // Config endpoint does not require user auth — the workflow author is - // already authenticated to Workspace. Google still sends a system token, - // but we don't gate the config preview on it. + // already authenticated to Workspace. return c.json({ - renderActions: { - action: { - navigations: [ + sections: [ + { + header: 'ChittyCommand — Roux Ingest', + widgets: [ + { + textParagraph: { + text: + 'Routes the triggering Gmail message into ChittyCommand as a triage intent. ' + + 'ChittyRoux derives privilege (privileged/pii/hoa_evidentiary/public) and ' + + 'space (business/legalink) from message classification and applies the gate.', + }, + }, { - pushCard: { - sections: [ - { - header: 'ChittyCommand — Roux Ingest', - widgets: [ - { - textParagraph: { - text: - 'Routes the triggering Gmail message into ChittyCommand as a triage intent. ' + - 'ChittyRoux derives privilege (privileged/pii/hoa_evidentiary/public) and ' + - 'space (business/legalink) from message classification and applies the gate.', - }, - }, - { - textInput: { - name: 'chittycommand_url', - label: 'ChittyCommand endpoint', - value: 'https://command.chitty.cc', - }, - }, - { - textInput: { - name: 'default_privilege', - label: 'Default privilege if classification fails', - value: 'public', - }, - }, - ], - }, - ], + textInput: { + name: 'chittycommand_url', + label: 'ChittyCommand endpoint', + value: 'https://command.chitty.cc', + }, + }, + { + textInput: { + name: 'default_privilege', + label: 'Default privilege if classification fails', + value: 'public', }, }, ], }, - }, + ], }); }); From c2d023e3d126676b2a527c2ac6090a8b27ec8e7d Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 13:40:30 +0000 Subject: [PATCH 9/9] fix(tests): honor SKIP_INTEGRATION in workspace-studio-ingest DB hooks Co-Authored-By: Claude Opus 4.7 --- tests/routes/workspace-studio-ingest.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/routes/workspace-studio-ingest.spec.ts b/tests/routes/workspace-studio-ingest.spec.ts index 884e4a3..dff45d3 100644 --- a/tests/routes/workspace-studio-ingest.spec.ts +++ b/tests/routes/workspace-studio-ingest.spec.ts @@ -111,6 +111,7 @@ beforeAll(async () => { CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID: CLIENT_ID, GCP_JWKS_URL: signer.jwksUrl, }; + if (process.env.SKIP_INTEGRATION === '1') return; if (DATABASE_URL) { const sql = neon(DATABASE_URL); await sql`DELETE FROM cc_goals WHERE title LIKE ${TEST_TAG + '%'}`; @@ -119,6 +120,7 @@ beforeAll(async () => { afterAll(async () => { if (signer) await new Promise((r) => signer.server.close(() => r())); + if (process.env.SKIP_INTEGRATION === '1') return; if (DATABASE_URL) { const sql = neon(DATABASE_URL); await sql`DELETE FROM cc_goals WHERE title LIKE ${TEST_TAG + '%'}`;