From d7257fac9436e8bb132780114c392a23d888dea3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:30 +0000 Subject: [PATCH 01/15] feat(integrations): add user_oauth_apps and user_integrations tables Integration config lived in the values store as _integration: JSON blobs, with the OAuth client id in a separate plain user value reached through a clientIdValueName pointer. Model an OAuth app (client credentials plus provider endpoints) separately from a connection (one connected account). Production has one Google app serving four connected accounts, so rotating client credentials was four writes with four chances to half-finish; it is now one. Composite (user_id, slug) keys and a composite foreign key keep per-user isolation structural rather than conventional. No token, refresh token, or client secret column exists in either table: those stay in secret_entries and are referenced by name. Backfill dedupes on the full app tuple rather than just the credential pair, so connections merge into one app only when they agree on every app-level field. Divergent rows split into separate apps instead of silently inheriting one row's endpoints. Fail-closed CHECK(0) assertions abort the transaction unless every migratable row produced exactly one connection with a resolvable app. Co-authored-by: Kent C. Dodds --- .../0100-user-oauth-apps-and-integrations.sql | 410 +++++++++++++ .../src/integrations/migration.node.test.ts | 579 ++++++++++++++++++ packages/worker/src/integrations/repo.ts | 496 +++++++++++++++ .../src/integrations/service.node.test.ts | 191 ++++++ packages/worker/src/integrations/service.ts | 465 ++++++++++++++ packages/worker/src/integrations/types.ts | 92 +++ tools/migration-ledger.json | 4 + 7 files changed, 2237 insertions(+) create mode 100644 packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql create mode 100644 packages/worker/src/integrations/migration.node.test.ts create mode 100644 packages/worker/src/integrations/repo.ts create mode 100644 packages/worker/src/integrations/service.node.test.ts create mode 100644 packages/worker/src/integrations/service.ts create mode 100644 packages/worker/src/integrations/types.ts diff --git a/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql b/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql new file mode 100644 index 0000000000..f04aad5a57 --- /dev/null +++ b/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql @@ -0,0 +1,410 @@ +-- First-class OAuth apps + connections, replacing `_integration:*` value blobs. +-- +-- An OAuth app (client credentials + provider endpoints) is distinct from a +-- connection (one connected account). Production has connections that share an +-- app (e.g. four Google connections, one client id + client secret) and pairs +-- that look similar but must stay separate apps when client secrets differ +-- (github vs github-kent). +-- +-- Composite primary/foreign keys keep per-user isolation structural: a bare +-- UUID app_id FK could theoretically point across users; (user_id, slug) cannot. +-- Tokens and client secrets stay in secret_entries, referenced by name only. +-- +-- Backfill reads user-scoped `_integration:*` values, resolves client_id from +-- the sibling value named by `$.clientIdValueName`, and dedupes apps by the +-- full app-level tuple (credentials + endpoints + flow options). Connections +-- merge into one app only when they agree on every app-level field; any +-- disagreement yields separate apps so no config is silently rewritten. +-- App slug is min(connection_name) per group; connection names are unique per +-- user and each connection is in exactly one group, so those minima cannot +-- collide. Then deletes only the migrated integration value rows. Client-id +-- values and any non-migratable `_integration:*` rows are left untouched. + +CREATE TABLE IF NOT EXISTS user_oauth_apps ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + provider TEXT NOT NULL, + label TEXT, + client_id TEXT NOT NULL, + client_secret_secret_name TEXT, + token_url TEXT NOT NULL, + authorize_url TEXT, + api_base_url TEXT, + flow TEXT NOT NULL CHECK (flow IN ('pkce', 'confidential')), + use_pkce INTEGER CHECK (use_pkce IN (0, 1)), + token_exchange_style TEXT CHECK (token_exchange_style IN ('form', 'basic-json', 'basic-form')), + scope_separator TEXT, + extra_authorize_params_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (user_id, slug) +); + +CREATE TABLE IF NOT EXISTS user_integrations ( + user_id TEXT NOT NULL, + name TEXT NOT NULL, + app_slug TEXT NOT NULL, + account_label TEXT, + description TEXT NOT NULL DEFAULT '', + scopes_json TEXT NOT NULL DEFAULT '[]', + required_hosts_json TEXT NOT NULL DEFAULT '[]', + access_token_secret_name TEXT NOT NULL, + refresh_token_secret_name TEXT, + connected_at TEXT, + token_refreshed_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (user_id, name), + FOREIGN KEY (user_id, app_slug) REFERENCES user_oauth_apps(user_id, slug) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_user_integrations_user_app_slug +ON user_integrations(user_id, app_slug); + +CREATE INDEX IF NOT EXISTS idx_user_oauth_apps_user_provider +ON user_oauth_apps(user_id, provider); + +-- Staging table so app insert + connection insert share one grouping definition. +CREATE TABLE __migration_0100_source ( + user_id TEXT NOT NULL, + connection_name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret_secret_name TEXT, + token_url TEXT NOT NULL, + authorize_url TEXT, + api_base_url TEXT, + flow TEXT NOT NULL, + use_pkce INTEGER, + token_exchange_style TEXT, + scope_separator TEXT, + extra_authorize_params_json TEXT NOT NULL, + scopes_json TEXT NOT NULL, + required_hosts_json TEXT NOT NULL, + access_token_secret_name TEXT NOT NULL, + refresh_token_secret_name TEXT +); + +INSERT INTO __migration_0100_source ( + user_id, + connection_name, + description, + created_at, + updated_at, + client_id, + client_secret_secret_name, + token_url, + authorize_url, + api_base_url, + flow, + use_pkce, + token_exchange_style, + scope_separator, + extra_authorize_params_json, + scopes_json, + required_hosts_json, + access_token_secret_name, + refresh_token_secret_name +) +SELECT + b.user_id, + substr(e.name, 14), + COALESCE(e.description, ''), + e.created_at, + e.updated_at, + trim(cid.value), + NULLIF(trim(json_extract(e.value, '$.clientSecretSecretName')), ''), + trim(json_extract(e.value, '$.tokenUrl')), + json_extract(e.value, '$.authorization.authorizeUrl'), + NULLIF(trim(json_extract(e.value, '$.apiBaseUrl')), ''), + json_extract(e.value, '$.flow'), + CASE + WHEN json_extract(e.value, '$.usePkce') IS NULL THEN NULL + WHEN json_extract(e.value, '$.usePkce') = 0 THEN 0 + ELSE 1 + END, + json_extract(e.value, '$.tokenExchangeStyle'), + json_extract(e.value, '$.authorization.scopeSeparator'), + COALESCE( + json_extract(e.value, '$.authorization.extraAuthorizeParams'), + '{}' + ), + COALESCE(json_extract(e.value, '$.authorization.scopes'), '[]'), + COALESCE(json_extract(e.value, '$.requiredHosts'), '[]'), + trim(json_extract(e.value, '$.accessTokenSecretName')), + NULLIF(trim(json_extract(e.value, '$.refreshTokenSecretName')), '') +FROM value_entries e +INNER JOIN value_buckets b ON b.id = e.bucket_id +INNER JOIN value_entries cid + ON cid.bucket_id = e.bucket_id + AND cid.name = json_extract(e.value, '$.clientIdValueName') +WHERE b.scope = 'user' + AND e.name LIKE '\_integration:%' ESCAPE '\' + AND json_valid(e.value) + AND json_extract(e.value, '$.tokenUrl') IS NOT NULL + AND trim(json_extract(e.value, '$.tokenUrl')) != '' + AND json_extract(e.value, '$.accessTokenSecretName') IS NOT NULL + AND trim(json_extract(e.value, '$.accessTokenSecretName')) != '' + AND json_extract(e.value, '$.flow') IN ('pkce', 'confidential') + AND trim(cid.value) != '' + AND ( + json_extract(e.value, '$.tokenExchangeStyle') IS NULL + OR json_extract(e.value, '$.tokenExchangeStyle') IN ( + 'form', + 'basic-json', + 'basic-form' + ) + ); + +CREATE TABLE __migration_0100_app_groups ( + user_id TEXT NOT NULL, + slug TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret_secret_name TEXT, + token_url TEXT NOT NULL, + authorize_url TEXT, + api_base_url TEXT, + flow TEXT NOT NULL, + use_pkce INTEGER, + token_exchange_style TEXT, + scope_separator TEXT, + extra_authorize_params_json TEXT NOT NULL, + PRIMARY KEY (user_id, slug) +); + +INSERT INTO __migration_0100_app_groups ( + user_id, + slug, + client_id, + client_secret_secret_name, + token_url, + authorize_url, + api_base_url, + flow, + use_pkce, + token_exchange_style, + scope_separator, + extra_authorize_params_json +) +SELECT + user_id, + min(connection_name) AS slug, + client_id, + client_secret_secret_name, + token_url, + authorize_url, + api_base_url, + flow, + use_pkce, + token_exchange_style, + scope_separator, + extra_authorize_params_json +FROM __migration_0100_source +GROUP BY + user_id, + client_id, + client_secret_secret_name, + token_url, + authorize_url, + api_base_url, + flow, + use_pkce, + token_exchange_style, + scope_separator, + extra_authorize_params_json; + +INSERT INTO user_oauth_apps ( + user_id, + slug, + provider, + label, + client_id, + client_secret_secret_name, + token_url, + authorize_url, + api_base_url, + flow, + use_pkce, + token_exchange_style, + scope_separator, + extra_authorize_params_json, + created_at, + updated_at +) +SELECT + g.user_id, + g.slug, + CASE + WHEN instr(g.slug, '-') > 0 THEN substr(g.slug, 1, instr(g.slug, '-') - 1) + ELSE g.slug + END AS provider, + NULL AS label, + g.client_id, + g.client_secret_secret_name, + g.token_url, + g.authorize_url, + g.api_base_url, + g.flow, + g.use_pkce, + g.token_exchange_style, + g.scope_separator, + g.extra_authorize_params_json, + s.created_at, + s.updated_at +FROM __migration_0100_app_groups g +INNER JOIN __migration_0100_source s + ON s.user_id = g.user_id + AND s.connection_name = g.slug; + +INSERT INTO user_integrations ( + user_id, + name, + app_slug, + account_label, + description, + scopes_json, + required_hosts_json, + access_token_secret_name, + refresh_token_secret_name, + connected_at, + token_refreshed_at, + created_at, + updated_at +) +SELECT + s.user_id, + s.connection_name AS name, + g.slug AS app_slug, + NULL AS account_label, + s.description, + s.scopes_json, + s.required_hosts_json, + s.access_token_secret_name, + s.refresh_token_secret_name, + NULL AS connected_at, + NULL AS token_refreshed_at, + s.created_at, + s.updated_at +FROM __migration_0100_source s +INNER JOIN __migration_0100_app_groups g + ON s.user_id = g.user_id + AND s.client_id IS g.client_id + AND s.client_secret_secret_name IS g.client_secret_secret_name + AND s.token_url IS g.token_url + AND s.authorize_url IS g.authorize_url + AND s.api_base_url IS g.api_base_url + AND s.flow IS g.flow + AND s.use_pkce IS g.use_pkce + AND s.token_exchange_style IS g.token_exchange_style + AND s.scope_separator IS g.scope_separator + AND s.extra_authorize_params_json IS g.extra_authorize_params_json; + +-- __migration_assertions intentionally uses CHECK (0) as an unreachable trap. +CREATE TABLE __migration_assertions ( + message TEXT NOT NULL CHECK (0) +); + +-- Every migratable `_integration:*` row must produce exactly one connection +-- (asserted before those value rows are deleted). +INSERT INTO __migration_assertions (message) +SELECT 'migratable _integration:* row count does not match user_integrations; aborting 0100.' +WHERE ( + SELECT count(*) FROM __migration_0100_source +) != (SELECT count(*) FROM user_integrations); + +INSERT INTO __migration_assertions (message) +SELECT 'migratable _integration:* row missing user_integrations connection; aborting 0100.' +WHERE EXISTS ( + SELECT 1 + FROM __migration_0100_source s + WHERE NOT EXISTS ( + SELECT 1 + FROM user_integrations i + WHERE i.user_id = s.user_id + AND i.name = s.connection_name + ) +); + +-- Every connection must resolve to an app in the same user scope. +INSERT INTO __migration_assertions (message) +SELECT 'user_integrations row missing resolvable user_oauth_apps parent; aborting 0100.' +WHERE EXISTS ( + SELECT 1 + FROM user_integrations i + WHERE NOT EXISTS ( + SELECT 1 + FROM user_oauth_apps a + WHERE a.user_id = i.user_id AND a.slug = i.app_slug + ) +); + +INSERT INTO __migration_assertions (message) +SELECT 'user_integrations.access_token_secret_name is NULL; aborting 0100.' +WHERE EXISTS ( + SELECT 1 + FROM user_integrations + WHERE access_token_secret_name IS NULL + OR trim(access_token_secret_name) = '' +); + +DELETE FROM value_entries +WHERE EXISTS ( + SELECT 1 + FROM value_buckets b + INNER JOIN value_entries cid + ON cid.bucket_id = value_entries.bucket_id + AND cid.name = json_extract(value_entries.value, '$.clientIdValueName') + WHERE b.id = value_entries.bucket_id + AND b.scope = 'user' + AND value_entries.name LIKE '\_integration:%' ESCAPE '\' + AND json_valid(value_entries.value) + AND json_extract(value_entries.value, '$.tokenUrl') IS NOT NULL + AND trim(json_extract(value_entries.value, '$.tokenUrl')) != '' + AND json_extract(value_entries.value, '$.accessTokenSecretName') IS NOT NULL + AND trim(json_extract(value_entries.value, '$.accessTokenSecretName')) != '' + AND json_extract(value_entries.value, '$.flow') IN ('pkce', 'confidential') + AND trim(cid.value) != '' + AND ( + json_extract(value_entries.value, '$.tokenExchangeStyle') IS NULL + OR json_extract(value_entries.value, '$.tokenExchangeStyle') IN ( + 'form', + 'basic-json', + 'basic-form' + ) + ) +); + +-- No migratable `_integration:*` rows may remain (malformed non-migratable rows may). +INSERT INTO __migration_assertions (message) +SELECT 'migratable _integration:* value rows remain after backfill; aborting 0100.' +WHERE EXISTS ( + SELECT 1 + FROM value_entries e + INNER JOIN value_buckets b ON b.id = e.bucket_id + INNER JOIN value_entries cid + ON cid.bucket_id = e.bucket_id + AND cid.name = json_extract(e.value, '$.clientIdValueName') + WHERE b.scope = 'user' + AND e.name LIKE '\_integration:%' ESCAPE '\' + AND json_valid(e.value) + AND json_extract(e.value, '$.tokenUrl') IS NOT NULL + AND trim(json_extract(e.value, '$.tokenUrl')) != '' + AND json_extract(e.value, '$.accessTokenSecretName') IS NOT NULL + AND trim(json_extract(e.value, '$.accessTokenSecretName')) != '' + AND json_extract(e.value, '$.flow') IN ('pkce', 'confidential') + AND trim(cid.value) != '' + AND ( + json_extract(e.value, '$.tokenExchangeStyle') IS NULL + OR json_extract(e.value, '$.tokenExchangeStyle') IN ( + 'form', + 'basic-json', + 'basic-form' + ) + ) +); + +DROP TABLE __migration_assertions; +DROP TABLE __migration_0100_app_groups; +DROP TABLE __migration_0100_source; diff --git a/packages/worker/src/integrations/migration.node.test.ts b/packages/worker/src/integrations/migration.node.test.ts new file mode 100644 index 0000000000..f0e8302fa5 --- /dev/null +++ b/packages/worker/src/integrations/migration.node.test.ts @@ -0,0 +1,579 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' +import { + parseIntegrationConfig, + type IntegrationConfig, +} from '#mcp/capabilities/integrations/integration-shared.ts' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { getJoinedIntegrationByName } from './repo.ts' +import { toIntegrationConfig } from './service.ts' + +const migrationsDirectory = new URL('../../migrations/', import.meta.url) +const oauthAppsMigration = '0100-user-oauth-apps-and-integrations.sql' + +function applyMigrationsBefore(db: DatabaseSync, exclusiveUpperBound: string) { + for (const fileName of readdirSync(migrationsDirectory) + .filter((file) => file.endsWith('.sql') && file < exclusiveUpperBound) + .sort()) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) + } +} + +function applyMigration(db: DatabaseSync, fileName: string) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) +} + +function insertUserBucket( + db: DatabaseSync, + input: { bucketId: string; userId: string }, +) { + db.prepare( + `INSERT INTO value_buckets ( + id, user_id, scope, binding_key, expires_at, created_at, updated_at + ) VALUES (?, ?, 'user', '', NULL, ?, ?)`, + ).run( + input.bucketId, + input.userId, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ) +} + +function insertValue( + db: DatabaseSync, + input: { + bucketId: string + name: string + value: string + description?: string + }, +) { + db.prepare( + `INSERT INTO value_entries ( + bucket_id, name, description, value, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + input.bucketId, + input.name, + input.description ?? '', + input.value, + '2026-02-01T00:00:00.000Z', + '2026-02-02T00:00:00.000Z', + ) +} + +function nHosts(count: number) { + return Array.from({ length: count }, (_, index) => `host${index}.example.com`) +} + +function nScopes(count: number) { + return Array.from({ length: count }, (_, index) => `scope.${index}`) +} + +function googleConfig(input: { + name: string + scopeCount: number + hostCount: number + accessTokenSecretName: string + refreshTokenSecretName: string +}) { + return { + name: input.name, + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + clientIdValueName: 'google-client-id', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: input.accessTokenSecretName, + refreshTokenSecretName: input.refreshTokenSecretName, + requiredHosts: nHosts(input.hostCount), + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: nScopes(input.scopeCount), + scopeSeparator: ' ', + extraAuthorizeParams: { access_type: 'offline', prompt: 'consent' }, + }, + } +} + +function expectedClientId(name: string) { + if (name.startsWith('google')) return 'google-client-id-value' + if (name.startsWith('github')) return 'shared-github-client-id' + if (name.startsWith('x')) return 'x-client-id-value' + return 'tesla-client-id-value' +} + +test('0100 migrates oauth apps/connections with production-shaped dedupe fixtures', async () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-a', userId: 'user-a' }) + insertUserBucket(db, { bucketId: 'bucket-b', userId: 'user-b' }) + + insertValue(db, { + bucketId: 'bucket-a', + name: 'google-client-id', + value: 'google-client-id-value', + }) + insertValue(db, { + bucketId: 'bucket-a', + name: 'github-client-id', + value: 'shared-github-client-id', + }) + insertValue(db, { + bucketId: 'bucket-a', + name: 'x-client-id', + value: 'x-client-id-value', + }) + insertValue(db, { + bucketId: 'bucket-a', + name: 'tesla-client-id', + value: 'tesla-client-id-value', + }) + insertValue(db, { + bucketId: 'bucket-b', + name: 'google-client-id', + value: 'google-client-id-value', + }) + + const googleFixtures = [ + googleConfig({ + name: 'google', + scopeCount: 11, + hostCount: 10, + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + }), + googleConfig({ + name: 'google-calendar', + scopeCount: 12, + hostCount: 10, + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + }), + googleConfig({ + name: 'google-mail', + scopeCount: 6, + hostCount: 7, + accessTokenSecretName: 'googleMailAccessToken', + refreshTokenSecretName: 'googleMailRefreshToken', + }), + googleConfig({ + name: 'google-drive', + scopeCount: 6, + hostCount: 7, + accessTokenSecretName: 'googleDriveAccessToken', + refreshTokenSecretName: 'googleDriveRefreshToken', + }), + ] + + const github = { + name: 'github', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential' as const, + clientIdValueName: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['github.com', 'api.github.com'], + } + const githubKent = { + ...github, + name: 'github-kent', + clientSecretSecretName: 'githubKentClientSecret', + accessTokenSecretName: 'githubKentAccessToken', + } + + const xConfig = { + name: 'x', + tokenUrl: 'https://api.twitter.com/2/oauth2/token', + apiBaseUrl: 'https://api.twitter.com', + flow: 'pkce' as const, + clientIdValueName: 'x-client-id', + clientSecretSecretName: null, + accessTokenSecretName: 'xAccessToken', + refreshTokenSecretName: 'xRefreshToken', + requiredHosts: ['api.twitter.com', 'twitter.com'], + authorization: { + authorizeUrl: 'https://twitter.com/i/oauth2/authorize', + scopes: ['tweet.read', 'users.read'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + } + const xKodykoala = { + ...xConfig, + name: 'x-kodykoala', + accessTokenSecretName: 'xKodykoalaAccessToken', + refreshTokenSecretName: 'xKodykoalaRefreshToken', + authorization: { + authorizeUrl: 'https://twitter.com/i/oauth2/authorize', + scopes: ['tweet.read'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + } + + const tesla = { + name: 'tesla', + tokenUrl: 'https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token', + apiBaseUrl: 'https://fleet-api.prd.vn.cloud.tesla.com', + flow: 'pkce' as const, + clientIdValueName: 'tesla-client-id', + clientSecretSecretName: null, + accessTokenSecretName: 'teslaAccessToken', + refreshTokenSecretName: 'teslaRefreshToken', + requiredHosts: ['fleet-api.prd.vn.cloud.tesla.com'], + } + + const malformed = { + name: 'broken', + apiBaseUrl: 'https://example.com', + flow: 'pkce', + clientIdValueName: 'google-client-id', + accessTokenSecretName: 'brokenAccessToken', + requiredHosts: ['example.com'], + } + + const userAConfigs = [ + ...googleFixtures, + github, + githubKent, + xConfig, + xKodykoala, + tesla, + ] + for (const config of userAConfigs) { + insertValue(db, { + bucketId: 'bucket-a', + name: `_integration:${config.name}`, + value: JSON.stringify(config), + }) + } + insertValue(db, { + bucketId: 'bucket-a', + name: '_integration:broken', + value: JSON.stringify(malformed), + }) + + const userBGoogle = googleConfig({ + name: 'google', + scopeCount: 11, + hostCount: 10, + accessTokenSecretName: 'userBGoogleAccessToken', + refreshTokenSecretName: 'userBGoogleRefreshToken', + }) + insertValue(db, { + bucketId: 'bucket-b', + name: '_integration:google', + value: JSON.stringify(userBGoogle), + }) + + const preMigrationByKey = new Map() + for (const config of [...userAConfigs, userBGoogle]) { + const parsed = parseIntegrationConfig(config, config.name) + expect(parsed).not.toBeNull() + if (!parsed) continue + const userId = config === userBGoogle ? 'user-b' : 'user-a' + preMigrationByKey.set(`${userId}:${parsed.name}`, parsed) + } + + applyMigration(db, oauthAppsMigration) + + const apps = db + .prepare( + `SELECT user_id, slug, client_id, client_secret_secret_name, authorize_url + FROM user_oauth_apps + ORDER BY user_id ASC, slug ASC`, + ) + .all() as Array<{ + user_id: string + slug: string + client_id: string + client_secret_secret_name: string | null + authorize_url: string | null + }> + + const connections = db + .prepare( + `SELECT user_id, name, app_slug, scopes_json, required_hosts_json, + access_token_secret_name, refresh_token_secret_name + FROM user_integrations + ORDER BY user_id ASC, name ASC`, + ) + .all() as Array<{ + user_id: string + name: string + app_slug: string + scopes_json: string + required_hosts_json: string + access_token_secret_name: string + refresh_token_secret_name: string | null + }> + + const userAApps = apps.filter((row) => row.user_id === 'user-a') + expect(userAApps.map((row) => row.slug).sort()).toEqual([ + 'github', + 'github-kent', + 'google', + 'tesla', + 'x', + ]) + + expect(userAApps.find((row) => row.slug === 'google')).toMatchObject({ + client_id: 'google-client-id-value', + client_secret_secret_name: 'googleClientSecret', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + }) + + const userAGoogleConnections = connections.filter( + (row) => row.user_id === 'user-a' && row.app_slug === 'google', + ) + expect(userAGoogleConnections).toHaveLength(4) + expect( + userAGoogleConnections.map((row) => ({ + name: row.name, + scopes: (JSON.parse(row.scopes_json) as Array).length, + hosts: (JSON.parse(row.required_hosts_json) as Array).length, + access: row.access_token_secret_name, + })), + ).toEqual([ + { + name: 'google', + scopes: 11, + hosts: 10, + access: 'googleAccessToken', + }, + { + name: 'google-calendar', + scopes: 12, + hosts: 10, + access: 'googleCalendarAccessToken', + }, + { + name: 'google-drive', + scopes: 6, + hosts: 7, + access: 'googleDriveAccessToken', + }, + { + name: 'google-mail', + scopes: 6, + hosts: 7, + access: 'googleMailAccessToken', + }, + ]) + + expect( + userAApps.filter((row) => row.client_id === 'shared-github-client-id'), + ).toHaveLength(2) + expect( + userAApps.find((row) => row.slug === 'github')?.client_secret_secret_name, + ).toBe('githubClientSecret') + expect( + userAApps.find((row) => row.slug === 'github-kent') + ?.client_secret_secret_name, + ).toBe('githubKentClientSecret') + + expect(userAApps.find((row) => row.slug === 'x')).toMatchObject({ + client_id: 'x-client-id-value', + client_secret_secret_name: null, + }) + expect( + connections.filter( + (row) => row.user_id === 'user-a' && row.app_slug === 'x', + ), + ).toHaveLength(2) + + expect( + userAApps.find((row) => row.slug === 'tesla')?.authorize_url, + ).toBeNull() + + const survivingValues = db + .prepare( + `SELECT e.name + FROM value_entries e + INNER JOIN value_buckets b ON b.id = e.bucket_id + WHERE b.user_id = 'user-a' + ORDER BY e.name ASC`, + ) + .all() as Array<{ name: string }> + expect(survivingValues.map((row) => row.name)).toEqual([ + '_integration:broken', + 'github-client-id', + 'google-client-id', + 'tesla-client-id', + 'x-client-id', + ]) + + expect(apps.filter((row) => row.user_id === 'user-b')).toEqual([ + expect.objectContaining({ + user_id: 'user-b', + slug: 'google', + client_id: 'google-client-id-value', + }), + ]) + expect( + connections + .filter((row) => row.user_id === 'user-b') + .map((row) => row.name), + ).toEqual(['google']) + + const d1 = createD1FromSqlite(db) + for (const [key, before] of preMigrationByKey) { + const [userId, name] = key.split(':') as [string, string] + const joined = await getJoinedIntegrationByName({ + db: d1, + userId, + name, + }) + expect(joined).not.toBeNull() + if (!joined) continue + const after = toIntegrationConfig(joined.app, joined.connection) + const { clientIdValueName: _ignored, ...rest } = before + expect(after).toEqual({ + ...rest, + clientId: expectedClientId(name), + }) + } + + const userAListed = await getJoinedIntegrationByName({ + db: d1, + userId: 'user-a', + name: 'google', + }) + expect(userAListed?.connection.accessTokenSecretName).toBe( + 'googleAccessToken', + ) + const userBListed = await getJoinedIntegrationByName({ + db: d1, + userId: 'user-b', + name: 'google', + }) + expect(userBListed?.connection.accessTokenSecretName).toBe( + 'userBGoogleAccessToken', + ) + expect(userAListed?.app.userId).toBe('user-a') + expect(userBListed?.app.userId).toBe('user-b') + + // Production shared-app shape still holds under full-tuple dedupe: + // google→1 app/4 connections, x→1 app/2, github→2 apps (secret names differ), + // plus singletons. That account's 11 singletons + these groups = 15 apps / 19 + // connections when every shared group agrees on app-level fields. + expect(userAApps).toHaveLength(5) + expect(connections.filter((row) => row.user_id === 'user-a')).toHaveLength(9) +}) + +test('0100 splits apps when credentials match but token_url differs', async () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-split', userId: 'user-split' }) + insertValue(db, { + bucketId: 'bucket-split', + name: 'shared-client-id', + value: 'same-client-id', + }) + + const left = { + name: 'provider-alpha', + tokenUrl: 'https://auth.example.com/oauth/token', + apiBaseUrl: 'https://api.example.com', + flow: 'pkce' as const, + clientIdValueName: 'shared-client-id', + clientSecretSecretName: 'sharedClientSecret', + accessTokenSecretName: 'alphaAccessToken', + refreshTokenSecretName: 'alphaRefreshToken', + requiredHosts: ['api.example.com'], + authorization: { + authorizeUrl: 'https://auth.example.com/oauth/authorize', + scopes: ['read'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + } + const right = { + ...left, + name: 'provider-beta', + tokenUrl: 'https://auth.example.com/oauth/v2/token', + accessTokenSecretName: 'betaAccessToken', + refreshTokenSecretName: 'betaRefreshToken', + authorization: { + ...left.authorization, + scopes: ['write'], + }, + } + + for (const config of [left, right]) { + insertValue(db, { + bucketId: 'bucket-split', + name: `_integration:${config.name}`, + value: JSON.stringify(config), + }) + } + + applyMigration(db, oauthAppsMigration) + + const apps = db + .prepare( + `SELECT slug, client_id, client_secret_secret_name, token_url + FROM user_oauth_apps + WHERE user_id = 'user-split' + ORDER BY slug ASC`, + ) + .all() as Array<{ + slug: string + client_id: string + client_secret_secret_name: string | null + token_url: string + }> + expect(apps).toEqual([ + { + slug: 'provider-alpha', + client_id: 'same-client-id', + client_secret_secret_name: 'sharedClientSecret', + token_url: 'https://auth.example.com/oauth/token', + }, + { + slug: 'provider-beta', + client_id: 'same-client-id', + client_secret_secret_name: 'sharedClientSecret', + token_url: 'https://auth.example.com/oauth/v2/token', + }, + ]) + + const connections = db + .prepare( + `SELECT name, app_slug + FROM user_integrations + WHERE user_id = 'user-split' + ORDER BY name ASC`, + ) + .all() as Array<{ name: string; app_slug: string }> + expect(connections).toEqual([ + { name: 'provider-alpha', app_slug: 'provider-alpha' }, + { name: 'provider-beta', app_slug: 'provider-beta' }, + ]) + + const d1 = createD1FromSqlite(db) + const alpha = await getJoinedIntegrationByName({ + db: d1, + userId: 'user-split', + name: 'provider-alpha', + }) + const beta = await getJoinedIntegrationByName({ + db: d1, + userId: 'user-split', + name: 'provider-beta', + }) + expect(alpha?.app.tokenUrl).toBe('https://auth.example.com/oauth/token') + expect(beta?.app.tokenUrl).toBe('https://auth.example.com/oauth/v2/token') + expect(toIntegrationConfig(alpha!.app, alpha!.connection).tokenUrl).toBe( + 'https://auth.example.com/oauth/token', + ) + expect(toIntegrationConfig(beta!.app, beta!.connection).tokenUrl).toBe( + 'https://auth.example.com/oauth/v2/token', + ) +}) diff --git a/packages/worker/src/integrations/repo.ts b/packages/worker/src/integrations/repo.ts new file mode 100644 index 0000000000..df93e8216f --- /dev/null +++ b/packages/worker/src/integrations/repo.ts @@ -0,0 +1,496 @@ +import { + type UserIntegrationConnection, + type UserIntegrationRow, + type UserOauthApp, + type UserOauthAppRow, + type UserOauthAppWithConnectionCount, +} from './types.ts' + +type JoinedIntegrationRow = UserOauthAppRow & { + connection_name: string + app_slug: string + account_label: string | null + description: string + scopes_json: string + required_hosts_json: string + access_token_secret_name: string + refresh_token_secret_name: string | null + connected_at: string | null + token_refreshed_at: string | null + connection_created_at: string + connection_updated_at: string +} + +type UserOauthAppWithCountRow = UserOauthAppRow & { + connection_count: number +} + +const appSelectColumns = ` + user_id, slug, provider, label, client_id, client_secret_secret_name, + token_url, authorize_url, api_base_url, flow, use_pkce, token_exchange_style, + scope_separator, extra_authorize_params_json, created_at, updated_at +` + +const joinedSelectColumns = ` + a.user_id AS user_id, + a.slug AS slug, + a.provider AS provider, + a.label AS label, + a.client_id AS client_id, + a.client_secret_secret_name AS client_secret_secret_name, + a.token_url AS token_url, + a.authorize_url AS authorize_url, + a.api_base_url AS api_base_url, + a.flow AS flow, + a.use_pkce AS use_pkce, + a.token_exchange_style AS token_exchange_style, + a.scope_separator AS scope_separator, + a.extra_authorize_params_json AS extra_authorize_params_json, + a.created_at AS created_at, + a.updated_at AS updated_at, + i.name AS connection_name, + i.app_slug AS app_slug, + i.account_label AS account_label, + i.description AS description, + i.scopes_json AS scopes_json, + i.required_hosts_json AS required_hosts_json, + i.access_token_secret_name AS access_token_secret_name, + i.refresh_token_secret_name AS refresh_token_secret_name, + i.connected_at AS connected_at, + i.token_refreshed_at AS token_refreshed_at, + i.created_at AS connection_created_at, + i.updated_at AS connection_updated_at +` + +export async function listJoinedIntegrationsForUser(input: { + db: D1Database + userId: string +}): Promise< + Array<{ app: UserOauthApp; connection: UserIntegrationConnection }> +> { + const result = await input.db + .prepare( + `SELECT ${joinedSelectColumns} + FROM user_integrations i + INNER JOIN user_oauth_apps a + ON a.user_id = i.user_id AND a.slug = i.app_slug + WHERE i.user_id = ? + ORDER BY i.name ASC`, + ) + .bind(input.userId) + .all() + return (result.results ?? []).map(mapJoinedRow) +} + +export async function getJoinedIntegrationByName(input: { + db: D1Database + userId: string + name: string +}): Promise<{ + app: UserOauthApp + connection: UserIntegrationConnection +} | null> { + const row = await input.db + .prepare( + `SELECT ${joinedSelectColumns} + FROM user_integrations i + INNER JOIN user_oauth_apps a + ON a.user_id = i.user_id AND a.slug = i.app_slug + WHERE i.user_id = ? AND i.name = ? + LIMIT 1`, + ) + .bind(input.userId, input.name) + .first() + return row ? mapJoinedRow(row) : null +} + +export async function getOauthAppBySlug(input: { + db: D1Database + userId: string + slug: string +}): Promise { + const row = await input.db + .prepare( + `SELECT ${appSelectColumns} + FROM user_oauth_apps + WHERE user_id = ? AND slug = ? + LIMIT 1`, + ) + .bind(input.userId, input.slug) + .first() + return row ? mapOauthAppRow(row) : null +} + +export async function findOauthAppByClientCredentials(input: { + db: D1Database + userId: string + clientId: string + clientSecretSecretName: string | null +}): Promise { + const row = + input.clientSecretSecretName == null + ? await input.db + .prepare( + `SELECT ${appSelectColumns} + FROM user_oauth_apps + WHERE user_id = ? + AND client_id = ? + AND client_secret_secret_name IS NULL + LIMIT 1`, + ) + .bind(input.userId, input.clientId) + .first() + : await input.db + .prepare( + `SELECT ${appSelectColumns} + FROM user_oauth_apps + WHERE user_id = ? + AND client_id = ? + AND client_secret_secret_name = ? + LIMIT 1`, + ) + .bind(input.userId, input.clientId, input.clientSecretSecretName) + .first() + return row ? mapOauthAppRow(row) : null +} + +/** Match an app only when the full app-level tuple agrees (not just credentials). */ +export async function findOauthAppByAppTuple(input: { + db: D1Database + userId: string + clientId: string + clientSecretSecretName: string | null + tokenUrl: string + authorizeUrl: string | null + apiBaseUrl: string | null + flow: UserOauthAppRow['flow'] + usePkce: number | null + tokenExchangeStyle: UserOauthAppRow['token_exchange_style'] + scopeSeparator: string | null + extraAuthorizeParamsJson: string +}): Promise { + const row = await input.db + .prepare( + `SELECT ${appSelectColumns} + FROM user_oauth_apps + WHERE user_id = ? + AND client_id = ? + AND client_secret_secret_name IS ? + AND token_url = ? + AND authorize_url IS ? + AND api_base_url IS ? + AND flow = ? + AND use_pkce IS ? + AND token_exchange_style IS ? + AND scope_separator IS ? + AND extra_authorize_params_json = ? + LIMIT 1`, + ) + .bind( + input.userId, + input.clientId, + input.clientSecretSecretName, + input.tokenUrl, + input.authorizeUrl, + input.apiBaseUrl, + input.flow, + input.usePkce, + input.tokenExchangeStyle, + input.scopeSeparator, + input.extraAuthorizeParamsJson, + ) + .first() + return row ? mapOauthAppRow(row) : null +} + +export async function listOauthAppsWithConnectionCounts(input: { + db: D1Database + userId: string +}): Promise> { + const result = await input.db + .prepare( + `SELECT + a.user_id, a.slug, a.provider, a.label, a.client_id, + a.client_secret_secret_name, a.token_url, a.authorize_url, + a.api_base_url, a.flow, a.use_pkce, a.token_exchange_style, + a.scope_separator, a.extra_authorize_params_json, + a.created_at, a.updated_at, + ( + SELECT count(*) + FROM user_integrations i + WHERE i.user_id = a.user_id AND i.app_slug = a.slug + ) AS connection_count + FROM user_oauth_apps a + WHERE a.user_id = ? + ORDER BY a.slug ASC`, + ) + .bind(input.userId) + .all() + return (result.results ?? []).map((row) => ({ + ...mapOauthAppRow(row), + connectionCount: Number(row.connection_count), + })) +} + +export async function countConnectionsForApp(input: { + db: D1Database + userId: string + appSlug: string +}): Promise { + const row = await input.db + .prepare( + `SELECT count(*) AS count + FROM user_integrations + WHERE user_id = ? AND app_slug = ?`, + ) + .bind(input.userId, input.appSlug) + .first<{ count: number }>() + return Number(row?.count ?? 0) +} + +export async function upsertOauthApp(input: { + db: D1Database + row: Omit & { + created_at?: string + updated_at?: string + } +}): Promise { + const now = new Date().toISOString() + await input.db + .prepare( + `INSERT INTO user_oauth_apps ( + user_id, slug, provider, label, client_id, client_secret_secret_name, + token_url, authorize_url, api_base_url, flow, use_pkce, + token_exchange_style, scope_separator, extra_authorize_params_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, slug) + DO UPDATE SET + provider = excluded.provider, + label = excluded.label, + client_id = excluded.client_id, + client_secret_secret_name = excluded.client_secret_secret_name, + token_url = excluded.token_url, + authorize_url = excluded.authorize_url, + api_base_url = excluded.api_base_url, + flow = excluded.flow, + use_pkce = excluded.use_pkce, + token_exchange_style = excluded.token_exchange_style, + scope_separator = excluded.scope_separator, + extra_authorize_params_json = excluded.extra_authorize_params_json, + updated_at = excluded.updated_at`, + ) + .bind( + input.row.user_id, + input.row.slug, + input.row.provider, + input.row.label, + input.row.client_id, + input.row.client_secret_secret_name, + input.row.token_url, + input.row.authorize_url, + input.row.api_base_url, + input.row.flow, + input.row.use_pkce, + input.row.token_exchange_style, + input.row.scope_separator, + input.row.extra_authorize_params_json, + input.row.created_at ?? now, + input.row.updated_at ?? now, + ) + .run() +} + +export async function updateOauthAppClientCredentials(input: { + db: D1Database + userId: string + slug: string + clientId: string + clientSecretSecretName: string | null + updatedAt?: string +}): Promise { + const result = await input.db + .prepare( + `UPDATE user_oauth_apps + SET client_id = ?, + client_secret_secret_name = ?, + updated_at = ? + WHERE user_id = ? AND slug = ?`, + ) + .bind( + input.clientId, + input.clientSecretSecretName, + input.updatedAt ?? new Date().toISOString(), + input.userId, + input.slug, + ) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function deleteOauthApp(input: { + db: D1Database + userId: string + slug: string +}): Promise { + const result = await input.db + .prepare( + `DELETE FROM user_oauth_apps + WHERE user_id = ? AND slug = ?`, + ) + .bind(input.userId, input.slug) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export async function upsertIntegrationConnection(input: { + db: D1Database + row: Omit & { + created_at?: string + updated_at?: string + } +}): Promise { + const now = new Date().toISOString() + await input.db + .prepare( + `INSERT INTO user_integrations ( + user_id, name, app_slug, account_label, description, scopes_json, + required_hosts_json, access_token_secret_name, refresh_token_secret_name, + connected_at, token_refreshed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, name) + DO UPDATE SET + app_slug = excluded.app_slug, + account_label = excluded.account_label, + description = excluded.description, + scopes_json = excluded.scopes_json, + required_hosts_json = excluded.required_hosts_json, + access_token_secret_name = excluded.access_token_secret_name, + refresh_token_secret_name = excluded.refresh_token_secret_name, + connected_at = excluded.connected_at, + token_refreshed_at = excluded.token_refreshed_at, + updated_at = excluded.updated_at`, + ) + .bind( + input.row.user_id, + input.row.name, + input.row.app_slug, + input.row.account_label, + input.row.description, + input.row.scopes_json, + input.row.required_hosts_json, + input.row.access_token_secret_name, + input.row.refresh_token_secret_name, + input.row.connected_at, + input.row.token_refreshed_at, + input.row.created_at ?? now, + input.row.updated_at ?? now, + ) + .run() +} + +export async function deleteIntegrationConnection(input: { + db: D1Database + userId: string + name: string +}): Promise { + const result = await input.db + .prepare( + `DELETE FROM user_integrations + WHERE user_id = ? AND name = ?`, + ) + .bind(input.userId, input.name) + .run() + return (result.meta.changes ?? 0) > 0 +} + +export function mapOauthAppRow(row: UserOauthAppRow): UserOauthApp { + return { + userId: row.user_id, + slug: row.slug, + provider: row.provider, + label: row.label, + clientId: row.client_id, + clientSecretSecretName: row.client_secret_secret_name, + tokenUrl: row.token_url, + authorizeUrl: row.authorize_url, + apiBaseUrl: row.api_base_url, + flow: row.flow, + usePkce: row.use_pkce == null ? null : row.use_pkce === 1, + tokenExchangeStyle: row.token_exchange_style, + scopeSeparator: row.scope_separator, + extraAuthorizeParams: parseJsonObject(row.extra_authorize_params_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function mapIntegrationRow( + row: UserIntegrationRow, +): UserIntegrationConnection { + return { + userId: row.user_id, + name: row.name, + appSlug: row.app_slug, + accountLabel: row.account_label, + description: row.description, + scopes: parseJsonStringArray(row.scopes_json), + requiredHosts: parseJsonStringArray(row.required_hosts_json), + accessTokenSecretName: row.access_token_secret_name, + refreshTokenSecretName: row.refresh_token_secret_name, + connectedAt: row.connected_at, + tokenRefreshedAt: row.token_refreshed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function mapJoinedRow(row: JoinedIntegrationRow): { + app: UserOauthApp + connection: UserIntegrationConnection +} { + return { + app: mapOauthAppRow(row), + connection: mapIntegrationRow({ + user_id: row.user_id, + name: row.connection_name, + app_slug: row.app_slug, + account_label: row.account_label, + description: row.description, + scopes_json: row.scopes_json, + required_hosts_json: row.required_hosts_json, + access_token_secret_name: row.access_token_secret_name, + refresh_token_secret_name: row.refresh_token_secret_name, + connected_at: row.connected_at, + token_refreshed_at: row.token_refreshed_at, + created_at: row.connection_created_at, + updated_at: row.connection_updated_at, + }), + } +} + +function parseJsonObject(raw: string): Record { + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + const result: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') result[key] = value + } + return result + } catch { + return {} + } +} + +function parseJsonStringArray(raw: string): Array { + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((entry): entry is string => typeof entry === 'string') + } catch { + return [] + } +} diff --git a/packages/worker/src/integrations/service.node.test.ts b/packages/worker/src/integrations/service.node.test.ts new file mode 100644 index 0000000000..b78c9d457b --- /dev/null +++ b/packages/worker/src/integrations/service.node.test.ts @@ -0,0 +1,191 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { + deleteOauthAppIfUnused, + getIntegration, + listIntegrations, + listOauthApps, + rotateOauthAppClientCredentials, + upsertIntegration, +} from './service.ts' + +const migrationsDirectory = new URL('../../migrations/', import.meta.url) + +function applyAllMigrations(db: DatabaseSync) { + for (const fileName of readdirSync(migrationsDirectory) + .filter((file) => file.endsWith('.sql')) + .sort()) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) + } +} + +function createEnv() { + const sqlite = new DatabaseSync(':memory:') + applyAllMigrations(sqlite) + return { + sqlite, + env: { APP_DB: createD1FromSqlite(sqlite) } as Pick, + } +} + +const baseGoogleConfig = { + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + clientId: 'google-client-id-value', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com', 'accounts.google.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, +} + +test('upsertIntegration reuses an existing app when the full app tuple matches', async () => { + const { env } = createEnv() + const userId = 'user-upsert' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-calendar', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + authorization: { + ...baseGoogleConfig.authorization, + scopes: ['calendar.readonly'], + }, + requiredHosts: ['www.googleapis.com'], + }, + }) + + const apps = await listOauthApps({ env, userId }) + expect(apps).toHaveLength(1) + expect(apps[0]).toMatchObject({ + slug: 'google', + connectionCount: 2, + clientId: 'google-client-id-value', + }) + + const listed = await listIntegrations({ env, userId }) + expect(listed.map((entry) => entry.name).sort()).toEqual([ + 'google', + 'google-calendar', + ]) + expect( + listed.every((entry) => entry.clientId === 'google-client-id-value'), + ).toBe(true) +}) + +test('upsertIntegration does not reuse an app when endpoints differ', async () => { + const { env } = createEnv() + const userId = 'user-upsert-split' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-legacy', + tokenUrl: 'https://oauth2.googleapis.com/token/legacy', + accessTokenSecretName: 'googleLegacyAccessToken', + refreshTokenSecretName: 'googleLegacyRefreshToken', + }, + }) + + const apps = await listOauthApps({ env, userId }) + expect(apps).toHaveLength(2) + expect(apps.map((app) => app.slug).sort()).toEqual([ + 'google', + 'google-legacy', + ]) + expect(apps.find((app) => app.slug === 'google')?.tokenUrl).toBe( + 'https://oauth2.googleapis.com/token', + ) + expect(apps.find((app) => app.slug === 'google-legacy')?.tokenUrl).toBe( + 'https://oauth2.googleapis.com/token/legacy', + ) + + const google = await getIntegration({ env, userId, name: 'google' }) + const legacy = await getIntegration({ env, userId, name: 'google-legacy' }) + expect(google?.tokenUrl).toBe('https://oauth2.googleapis.com/token') + expect(legacy?.tokenUrl).toBe('https://oauth2.googleapis.com/token/legacy') +}) + +test('rotateOauthAppClientCredentials updates every sibling connection join', async () => { + const { env } = createEnv() + const userId = 'user-rotate' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-mail', + accessTokenSecretName: 'googleMailAccessToken', + refreshTokenSecretName: 'googleMailRefreshToken', + }, + }) + + const rotated = await rotateOauthAppClientCredentials({ + env, + userId, + slug: 'google', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + }) + expect(rotated).toMatchObject({ + slug: 'google', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + }) + + const google = await getIntegration({ env, userId, name: 'google' }) + const googleMail = await getIntegration({ env, userId, name: 'google-mail' }) + expect(google?.clientId).toBe('google-client-id-rotated') + expect(google?.clientSecretSecretName).toBe('googleClientSecretRotated') + expect(googleMail?.clientId).toBe('google-client-id-rotated') + expect(googleMail?.clientSecretSecretName).toBe('googleClientSecretRotated') +}) + +test('deleteOauthAppIfUnused is blocked while connections exist', async () => { + const { env } = createEnv() + const userId = 'user-delete-app' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + + await expect( + deleteOauthAppIfUnused({ env, userId, slug: 'google' }), + ).rejects.toThrow(/still has 1 connection/) + + const stillThere = await getIntegration({ env, userId, name: 'google' }) + expect(stillThere?.name).toBe('google') +}) diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts new file mode 100644 index 0000000000..55e6ae3d44 --- /dev/null +++ b/packages/worker/src/integrations/service.ts @@ -0,0 +1,465 @@ +import { + canonicalIntegrationName, + integrationConfigWithClientIdSchema, + normalizeIntegrationConfigWithClientId, + type IntegrationConfigWithClientId, +} from '#mcp/capabilities/integrations/integration-shared.ts' +import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' +import { + countConnectionsForApp, + deleteIntegrationConnection, + deleteOauthApp, + findOauthAppByAppTuple, + findOauthAppByClientCredentials, + getJoinedIntegrationByName, + getOauthAppBySlug, + listJoinedIntegrationsForUser, + listOauthAppsWithConnectionCounts, + updateOauthAppClientCredentials, + upsertIntegrationConnection, + upsertOauthApp, +} from './repo.ts' +import { + type JoinedIntegration, + type UserIntegrationConnection, + type UserOauthApp, + type UserOauthAppWithConnectionCount, +} from './types.ts' + +export type { IntegrationConfigWithClientId } + +export function toIntegrationConfig( + app: UserOauthApp, + connection: UserIntegrationConnection, +): IntegrationConfigWithClientId { + const authorization = + app.authorizeUrl == null + ? null + : { + authorizeUrl: app.authorizeUrl, + scopes: connection.scopes, + scopeSeparator: app.scopeSeparator, + extraAuthorizeParams: app.extraAuthorizeParams, + } + return normalizeIntegrationConfigWithClientId({ + name: connection.name, + tokenUrl: app.tokenUrl, + apiBaseUrl: app.apiBaseUrl, + flow: app.flow, + usePkce: app.usePkce, + clientId: app.clientId, + clientSecretSecretName: app.clientSecretSecretName, + accessTokenSecretName: connection.accessTokenSecretName, + refreshTokenSecretName: connection.refreshTokenSecretName, + requiredHosts: connection.requiredHosts, + tokenExchangeStyle: app.tokenExchangeStyle, + authorization, + }) +} + +export async function listIntegrations(input: { + env: Pick + userId: string +}): Promise> { + const rows = await listJoinedIntegrationsForUser({ + db: input.env.APP_DB, + userId: input.userId, + }) + return rows.map(({ app, connection }) => toIntegrationConfig(app, connection)) +} + +export async function listJoinedIntegrations(input: { + env: Pick + userId: string +}): Promise> { + return listJoinedIntegrationsForUser({ + db: input.env.APP_DB, + userId: input.userId, + }) +} + +export async function getIntegration(input: { + env: Pick + userId: string + name: string +}): Promise { + const joined = await getJoinedIntegration({ + env: input.env, + userId: input.userId, + name: input.name, + }) + if (!joined) return null + return toIntegrationConfig(joined.app, joined.connection) +} + +export async function getJoinedIntegration(input: { + env: Pick + userId: string + name: string +}): Promise { + const name = canonicalIntegrationName(input.name) + if (!name) return null + return getJoinedIntegrationByName({ + db: input.env.APP_DB, + userId: input.userId, + name, + }) +} + +export async function upsertIntegration(input: { + env: Pick + userId: string + config: IntegrationConfigWithClientId + description?: string | null + accountLabel?: string | null +}): Promise { + const parsed = integrationConfigWithClientIdSchema.parse(input.config) + const config = normalizeIntegrationConfigWithClientId(parsed) + const now = new Date().toISOString() + const existing = await getJoinedIntegrationByName({ + db: input.env.APP_DB, + userId: input.userId, + name: config.name, + }) + + const appRowFields = buildOauthAppRow({ + userId: input.userId, + slug: config.name, + config, + label: null, + createdAt: now, + updatedAt: now, + }) + const matchedApp = await findOauthAppByAppTuple({ + db: input.env.APP_DB, + userId: input.userId, + clientId: appRowFields.client_id, + clientSecretSecretName: appRowFields.client_secret_secret_name, + tokenUrl: appRowFields.token_url, + authorizeUrl: appRowFields.authorize_url, + apiBaseUrl: appRowFields.api_base_url, + flow: appRowFields.flow, + usePkce: appRowFields.use_pkce, + tokenExchangeStyle: appRowFields.token_exchange_style, + scopeSeparator: appRowFields.scope_separator, + extraAuthorizeParamsJson: appRowFields.extra_authorize_params_json, + }) + + let appSlug: string + if (matchedApp) { + appSlug = matchedApp.slug + await upsertOauthApp({ + db: input.env.APP_DB, + row: { + ...appRowFields, + slug: matchedApp.slug, + label: matchedApp.label, + created_at: matchedApp.createdAt, + updated_at: now, + }, + }) + } else if (existing) { + const siblingCount = await countConnectionsForApp({ + db: input.env.APP_DB, + userId: input.userId, + appSlug: existing.app.slug, + }) + if (siblingCount <= 1) { + appSlug = existing.app.slug + await upsertOauthApp({ + db: input.env.APP_DB, + row: { + ...appRowFields, + slug: existing.app.slug, + label: existing.app.label, + created_at: existing.app.createdAt, + updated_at: now, + }, + }) + } else { + appSlug = await allocateAppSlug({ + db: input.env.APP_DB, + userId: input.userId, + preferredSlug: config.name, + }) + await upsertOauthApp({ + db: input.env.APP_DB, + row: { + ...appRowFields, + slug: appSlug, + label: null, + created_at: now, + updated_at: now, + }, + }) + } + } else { + appSlug = await allocateAppSlug({ + db: input.env.APP_DB, + userId: input.userId, + preferredSlug: config.name, + }) + await upsertOauthApp({ + db: input.env.APP_DB, + row: { + ...appRowFields, + slug: appSlug, + label: null, + created_at: now, + updated_at: now, + }, + }) + } + + await upsertIntegrationConnection({ + db: input.env.APP_DB, + row: { + user_id: input.userId, + name: config.name, + app_slug: appSlug, + account_label: + input.accountLabel === undefined + ? (existing?.connection.accountLabel ?? null) + : input.accountLabel?.trim() || null, + description: + input.description === undefined + ? (existing?.connection.description ?? '') + : (input.description?.trim() ?? ''), + scopes_json: JSON.stringify(config.authorization?.scopes ?? []), + required_hosts_json: JSON.stringify( + normalizeAllowedHosts(config.requiredHosts ?? []), + ), + access_token_secret_name: config.accessTokenSecretName, + refresh_token_secret_name: config.refreshTokenSecretName ?? null, + connected_at: existing?.connection.connectedAt ?? null, + token_refreshed_at: existing?.connection.tokenRefreshedAt ?? null, + created_at: existing?.connection.createdAt ?? now, + updated_at: now, + }, + }) + + const saved = await getIntegration({ + env: input.env, + userId: input.userId, + name: config.name, + }) + if (!saved) { + throw new Error(`Failed to upsert integration "${config.name}".`) + } + return saved +} + +export async function deleteIntegration(input: { + env: Pick + userId: string + name: string +}): Promise { + const name = canonicalIntegrationName(input.name) + if (!name) return false + const existing = await getJoinedIntegrationByName({ + db: input.env.APP_DB, + userId: input.userId, + name, + }) + if (!existing) return false + const deleted = await deleteIntegrationConnection({ + db: input.env.APP_DB, + userId: input.userId, + name, + }) + if (!deleted) return false + const remaining = await countConnectionsForApp({ + db: input.env.APP_DB, + userId: input.userId, + appSlug: existing.app.slug, + }) + if (remaining === 0) { + await deleteOauthApp({ + db: input.env.APP_DB, + userId: input.userId, + slug: existing.app.slug, + }) + } + return true +} + +export async function listOauthApps(input: { + env: Pick + userId: string +}): Promise> { + return listOauthAppsWithConnectionCounts({ + db: input.env.APP_DB, + userId: input.userId, + }) +} + +export async function getOauthApp(input: { + env: Pick + userId: string + slug: string +}): Promise { + const slug = input.slug.trim() + if (!slug) return null + return getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) +} + +export async function rotateOauthAppClientCredentials(input: { + env: Pick + userId: string + slug: string + clientId: string + clientSecretSecretName?: string | null +}): Promise { + const slug = input.slug.trim() + const clientId = input.clientId.trim() + if (!slug) throw new Error('OAuth app slug is required.') + if (!clientId) throw new Error('Client id is required.') + const clientSecretSecretName = + input.clientSecretSecretName == null + ? null + : input.clientSecretSecretName.trim() || null + + const existing = await getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) + if (!existing) { + throw new Error(`OAuth app "${slug}" was not found for this user.`) + } + + const conflicting = await findOauthAppByClientCredentials({ + db: input.env.APP_DB, + userId: input.userId, + clientId, + clientSecretSecretName, + }) + if (conflicting && conflicting.slug !== slug) { + throw new Error( + `Another OAuth app ("${conflicting.slug}") already uses these client credentials.`, + ) + } + + const updated = await updateOauthAppClientCredentials({ + db: input.env.APP_DB, + userId: input.userId, + slug, + clientId, + clientSecretSecretName, + }) + if (!updated) { + throw new Error(`Failed to rotate credentials for OAuth app "${slug}".`) + } + const app = await getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) + if (!app) { + throw new Error(`OAuth app "${slug}" was not found after rotation.`) + } + return app +} + +export async function deleteOauthAppIfUnused(input: { + env: Pick + userId: string + slug: string +}): Promise { + const slug = input.slug.trim() + if (!slug) return false + const existing = await getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) + if (!existing) return false + const connectionCount = await countConnectionsForApp({ + db: input.env.APP_DB, + userId: input.userId, + appSlug: existing.slug, + }) + if (connectionCount > 0) { + throw new Error( + `OAuth app "${slug}" still has ${connectionCount} connection${connectionCount === 1 ? '' : 's'}.`, + ) + } + return deleteOauthApp({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) +} + +function buildOauthAppRow(input: { + userId: string + slug: string + config: IntegrationConfigWithClientId + label: string | null + createdAt: string + updatedAt: string +}) { + const provider = providerFromSlug(input.slug) + const authorization = input.config.authorization ?? null + return { + user_id: input.userId, + slug: input.slug, + provider, + label: input.label, + client_id: input.config.clientId, + client_secret_secret_name: input.config.clientSecretSecretName ?? null, + token_url: input.config.tokenUrl, + authorize_url: authorization?.authorizeUrl ?? null, + api_base_url: input.config.apiBaseUrl ?? null, + flow: input.config.flow, + use_pkce: + typeof input.config.usePkce === 'boolean' + ? input.config.usePkce + ? 1 + : 0 + : null, + token_exchange_style: input.config.tokenExchangeStyle ?? null, + scope_separator: authorization?.scopeSeparator ?? null, + extra_authorize_params_json: JSON.stringify( + authorization?.extraAuthorizeParams ?? {}, + ), + created_at: input.createdAt, + updated_at: input.updatedAt, + } +} + +function providerFromSlug(slug: string) { + const separator = slug.indexOf('-') + return separator === -1 ? slug : slug.slice(0, separator) +} + +async function allocateAppSlug(input: { + db: D1Database + userId: string + preferredSlug: string +}): Promise { + const existing = await getOauthAppBySlug({ + db: input.db, + userId: input.userId, + slug: input.preferredSlug, + }) + if (!existing) return input.preferredSlug + let suffix = 2 + while (suffix < 10_000) { + const candidate = `${input.preferredSlug}-${suffix}` + const conflict = await getOauthAppBySlug({ + db: input.db, + userId: input.userId, + slug: candidate, + }) + if (!conflict) return candidate + suffix += 1 + } + throw new Error( + `Unable to allocate an OAuth app slug for "${input.preferredSlug}".`, + ) +} diff --git a/packages/worker/src/integrations/types.ts b/packages/worker/src/integrations/types.ts new file mode 100644 index 0000000000..ad917aabc6 --- /dev/null +++ b/packages/worker/src/integrations/types.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' +import { + integrationFlowValues, + tokenExchangeStyleValues, +} from '#mcp/capabilities/integrations/integration-shared.ts' +export const oauthAppFlowSchema = z.enum(integrationFlowValues) +export const oauthTokenExchangeStyleSchema = z.enum(tokenExchangeStyleValues) + +export const userOauthAppSchema = z.object({ + userId: z.string().min(1), + slug: z.string().min(1), + provider: z.string().min(1), + label: z.string().min(1).nullable(), + clientId: z.string().min(1), + clientSecretSecretName: z.string().min(1).nullable(), + tokenUrl: z.string().url(), + authorizeUrl: z.string().url().nullable(), + apiBaseUrl: z.string().url().nullable(), + flow: oauthAppFlowSchema, + usePkce: z.boolean().nullable(), + tokenExchangeStyle: oauthTokenExchangeStyleSchema.nullable(), + scopeSeparator: z.string().min(1).nullable(), + extraAuthorizeParams: z.record(z.string(), z.string()), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), +}) + +export type UserOauthApp = z.infer + +export const userIntegrationConnectionSchema = z.object({ + userId: z.string().min(1), + name: z.string().min(1), + appSlug: z.string().min(1), + accountLabel: z.string().min(1).nullable(), + description: z.string(), + scopes: z.array(z.string()), + requiredHosts: z.array(z.string()), + accessTokenSecretName: z.string().min(1), + refreshTokenSecretName: z.string().min(1).nullable(), + connectedAt: z.string().min(1).nullable(), + tokenRefreshedAt: z.string().min(1).nullable(), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), +}) + +export type UserIntegrationConnection = z.infer< + typeof userIntegrationConnectionSchema +> + +export type UserOauthAppWithConnectionCount = UserOauthApp & { + connectionCount: number +} + +export type JoinedIntegration = { + app: UserOauthApp + connection: UserIntegrationConnection +} + +export type UserOauthAppRow = { + user_id: string + slug: string + provider: string + label: string | null + client_id: string + client_secret_secret_name: string | null + token_url: string + authorize_url: string | null + api_base_url: string | null + flow: (typeof integrationFlowValues)[number] + use_pkce: number | null + token_exchange_style: (typeof tokenExchangeStyleValues)[number] | null + scope_separator: string | null + extra_authorize_params_json: string + created_at: string + updated_at: string +} + +export type UserIntegrationRow = { + user_id: string + name: string + app_slug: string + account_label: string | null + description: string + scopes_json: string + required_hosts_json: string + access_token_secret_name: string + refresh_token_secret_name: string | null + connected_at: string | null + token_refreshed_at: string | null + created_at: string + updated_at: string +} diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index 589347acf4..4f466674e5 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -423,6 +423,10 @@ { "filename": "0099-drop-run-history-legacy.sql", "sha256": "b3c25eb9855ddc2d15ec7c5d848e71e3fc25ddc8f4d247845a9c85216027bb26" + }, + { + "filename": "0100-user-oauth-apps-and-integrations.sql", + "sha256": "aecded421eb34155609388c1fdbf9738cee91dc677c558bed5ae73d84cf12489" } ] } From 4d31d73e9a35f5ccfd88bedceee25e3e9612ad05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:30 +0000 Subject: [PATCH 02/15] feat(integrations): read capabilities from D1 and add oauth app surface integration_save/get/list/delete keep their names and flat output shape so user package code and search are unaffected; clientIdValueName becomes clientId now that the id is stored inline. Add integration_oauth_app_list and integration_oauth_app_rotate_credentials so credential rotation is a single write across every connection sharing an app. Co-authored-by: Kent C. Dodds --- .../mcp/capabilities/integrations/domain.ts | 8 +- .../integrations/integration-delete.ts | 24 +- .../integrations/integration-get.ts | 41 +- .../integrations/integration-list.ts | 40 +- .../integration-oauth-app-list.ts | 71 +++ ...ntegration-oauth-app-rotate-credentials.ts | 95 +++ .../integration-save.node.test.ts | 572 +++++++++--------- .../integrations/integration-save.ts | 67 +- .../integrations/integration-shared.ts | 58 +- .../integrations/oauth-app-shared.ts | 67 ++ 10 files changed, 651 insertions(+), 392 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-list.ts create mode 100644 packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-rotate-credentials.ts create mode 100644 packages/worker/src/mcp/capabilities/integrations/oauth-app-shared.ts diff --git a/packages/worker/src/mcp/capabilities/integrations/domain.ts b/packages/worker/src/mcp/capabilities/integrations/domain.ts index 8b4804b023..6ee6d1b5e4 100644 --- a/packages/worker/src/mcp/capabilities/integrations/domain.ts +++ b/packages/worker/src/mcp/capabilities/integrations/domain.ts @@ -4,6 +4,8 @@ import { integrationDeleteCapability } from './integration-delete.ts' import { integrationDiscoverCapability } from './integration-discover.ts' import { integrationGetCapability } from './integration-get.ts' import { integrationListCapability } from './integration-list.ts' +import { integrationOauthAppListCapability } from './integration-oauth-app-list.ts' +import { integrationOauthAppRotateCredentialsCapability } from './integration-oauth-app-rotate-credentials.ts' import { integrationRegistrySearchCapability } from './integration-registry-search.ts' import { integrationSaveCapability } from './integration-save.ts' import { openapiClientScaffoldCapability } from './openapi-client-scaffold.ts' @@ -12,7 +14,7 @@ import { openapiSpecSummarizeCapability } from './openapi-spec-summarize.ts' export const integrationsDomain = defineDomain({ name: capabilityDomainNames.integrations, description: - 'Saved OAuth integration configs for the signed-in user (non-secret; credentials stay in the secret store), plus registry-backed provider discovery and OpenAPI spec summarization for researching provider auth contracts.', + 'Saved OAuth integration configs for the signed-in user (non-secret; credentials stay in the secret store), plus registry-backed provider discovery and OpenAPI spec summarization for researching provider auth contracts. OAuth apps (shared client credentials) can have many connections.', keywords: [ 'integration', 'oauth', @@ -26,12 +28,16 @@ export const integrationsDomain = defineDomain({ 'openapi', 'spec', 'scaffold', + 'oauth app', + 'client credentials', ], capabilities: [ integrationSaveCapability, integrationGetCapability, integrationListCapability, integrationDeleteCapability, + integrationOauthAppListCapability, + integrationOauthAppRotateCredentialsCapability, integrationRegistrySearchCapability, integrationDiscoverCapability, openapiSpecSummarizeCapability, diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-delete.ts b/packages/worker/src/mcp/capabilities/integrations/integration-delete.ts index aac660d200..a5ceb57176 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-delete.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-delete.ts @@ -3,8 +3,7 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { deleteValue } from '#mcp/values/service.ts' -import { buildIntegrationValueName } from './integration-shared.ts' +import { deleteIntegration } from '#worker/integrations/service.ts' const inputSchema = z.object({ name: z.string().min(1).describe('Integration name to delete.'), @@ -18,8 +17,16 @@ export const integrationDeleteCapability = defineDomainCapability( capabilityDomainNames.integrations, { name: 'integration_delete', - description: 'Delete a saved OAuth integration configuration by name.', - keywords: ['integration', 'oauth', 'config', 'registry', 'delete', 'value'], + description: + 'Delete a saved OAuth integration connection by name. When it is the last connection on its OAuth app, the unused app is removed too.', + keywords: [ + 'integration', + 'oauth', + 'config', + 'registry', + 'delete', + 'connection', + ], readOnly: false, idempotent: false, destructive: true, @@ -27,15 +34,10 @@ export const integrationDeleteCapability = defineDomainCapability( outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const deleted = await deleteValue({ + const deleted = await deleteIntegration({ env: ctx.env, userId: user.userId, - name: buildIntegrationValueName(args.name), - scope: 'user', - storageContext: { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - }, + name: args.name, }) return { deleted } }, diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-get.ts b/packages/worker/src/mcp/capabilities/integrations/integration-get.ts index d06c686087..a64e5f179c 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-get.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-get.ts @@ -3,20 +3,15 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { getValue } from '#mcp/values/service.ts' -import { - buildIntegrationValueName, - integrationConfigSchema, - parseIntegrationConfig, - parseIntegrationJson, -} from './integration-shared.ts' +import { getIntegration } from '#worker/integrations/service.ts' +import { integrationConfigWithClientIdSchema } from './integration-shared.ts' const inputSchema = z.object({ name: z.string().min(1).describe('Integration name to read.'), }) const outputSchema = z.object({ - integration: integrationConfigSchema.nullable(), + integration: integrationConfigWithClientIdSchema.nullable(), }) export const integrationGetCapability = defineDomainCapability( @@ -24,7 +19,14 @@ export const integrationGetCapability = defineDomainCapability( { name: 'integration_get', description: 'Read an OAuth integration configuration by name.', - keywords: ['integration', 'oauth', 'config', 'registry', 'read', 'value'], + keywords: [ + 'integration', + 'oauth', + 'config', + 'registry', + 'read', + 'connection', + ], readOnly: true, idempotent: true, destructive: false, @@ -32,27 +34,12 @@ export const integrationGetCapability = defineDomainCapability( outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const value = await getValue({ + const integration = await getIntegration({ env: ctx.env, userId: user.userId, - name: buildIntegrationValueName(args.name), - scope: 'user', - storageContext: { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - storageId: ctx.callerContext.storageContext?.storageId ?? null, - }, + name: args.name, }) - if (!value) { - return { integration: null } - } - const parsed = parseIntegrationConfig( - parseIntegrationJson(value.value), - args.name, - ) - return { - integration: parsed, - } + return { integration } }, }, ) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-list.ts b/packages/worker/src/mcp/capabilities/integrations/integration-list.ts index f14f1f3ede..0b95f3aca7 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-list.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-list.ts @@ -6,16 +6,11 @@ import { emptyCapabilityInputSchema, type CapabilityContext, } from '#mcp/capabilities/types.ts' -import { listValues } from '#mcp/values/service.ts' -import { - integrationConfigSchema, - parseIntegrationConfig, - parseIntegrationValueName, - parseIntegrationJson, -} from './integration-shared.ts' +import { listIntegrations } from '#worker/integrations/service.ts' +import { integrationConfigWithClientIdSchema } from './integration-shared.ts' const outputSchema = z.object({ - integrations: z.array(integrationConfigSchema), + integrations: z.array(integrationConfigWithClientIdSchema), }) export const integrationListCapability = defineDomainCapability( @@ -23,7 +18,14 @@ export const integrationListCapability = defineDomainCapability( { name: 'integration_list', description: 'List saved OAuth integration configurations.', - keywords: ['integration', 'oauth', 'config', 'registry', 'list', 'value'], + keywords: [ + 'integration', + 'oauth', + 'config', + 'registry', + 'list', + 'connection', + ], readOnly: true, idempotent: true, destructive: false, @@ -31,28 +33,10 @@ export const integrationListCapability = defineDomainCapability( outputSchema, async handler(_args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const values = await listValues({ + const integrations = await listIntegrations({ env: ctx.env, userId: user.userId, - scope: 'user', - storageContext: { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - storageId: ctx.callerContext.storageContext?.storageId ?? null, - }, }) - const integrations = values - .map((value) => { - const integrationName = parseIntegrationValueName(value.name) - if (!integrationName) return null - return parseIntegrationConfig( - parseIntegrationJson(value.value), - integrationName, - ) - }) - .filter((entry): entry is z.infer => - Boolean(entry), - ) return { integrations } }, }, diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-list.ts b/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-list.ts new file mode 100644 index 0000000000..05299cf63a --- /dev/null +++ b/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-list.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { + emptyCapabilityInputSchema, + type CapabilityContext, +} from '#mcp/capabilities/types.ts' +import { + listJoinedIntegrations, + listOauthApps, +} from '#worker/integrations/service.ts' +import { oauthAppPublicSchema, toOauthAppPublic } from './oauth-app-shared.ts' + +const outputSchema = z.object({ + apps: z.array(oauthAppPublicSchema), +}) + +export const integrationOauthAppListCapability = defineDomainCapability( + capabilityDomainNames.integrations, + { + name: 'integration_oauth_app_list', + description: + 'List OAuth apps for the signed-in user with connection counts and the connection names that share each app. Use this before rotating client credentials so one write updates every sibling connection.', + keywords: [ + 'integration', + 'oauth', + 'app', + 'client', + 'credentials', + 'list', + 'connections', + 'shared', + ], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: emptyCapabilityInputSchema, + outputSchema, + async handler(_args, ctx: CapabilityContext) { + const user = requireMcpUser(ctx.callerContext) + const [apps, joined] = await Promise.all([ + listOauthApps({ + env: ctx.env, + userId: user.userId, + }), + listJoinedIntegrations({ + env: ctx.env, + userId: user.userId, + }), + ]) + const connectionsByAppSlug = new Map< + string, + Array<{ name: string; accountLabel: string | null }> + >() + for (const { app, connection } of joined) { + const existing = connectionsByAppSlug.get(app.slug) ?? [] + existing.push({ + name: connection.name, + accountLabel: connection.accountLabel, + }) + connectionsByAppSlug.set(app.slug, existing) + } + return { + apps: apps.map((app) => + toOauthAppPublic(app, connectionsByAppSlug.get(app.slug) ?? []), + ), + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-rotate-credentials.ts b/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-rotate-credentials.ts new file mode 100644 index 0000000000..1a15bc0085 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/integrations/integration-oauth-app-rotate-credentials.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { + getOauthApp, + listJoinedIntegrations, + rotateOauthAppClientCredentials, +} from '#worker/integrations/service.ts' +import { oauthAppPublicSchema, toOauthAppPublic } from './oauth-app-shared.ts' + +const inputSchema = z + .object({ + slug: z + .string() + .min(1) + .describe('OAuth app slug whose client credentials should be rotated.'), + clientId: z.string().min(1).describe('New OAuth client id (inline value).'), + clientSecretSecretName: z + .string() + .min(1) + .nullable() + .optional() + .describe( + 'Secret-store name for the new client secret, or null to clear it. Omit to leave the existing secret name unchanged.', + ), + }) + .strict() + +const outputSchema = z.object({ + app: oauthAppPublicSchema, +}) + +export const integrationOauthAppRotateCredentialsCapability = + defineDomainCapability(capabilityDomainNames.integrations, { + name: 'integration_oauth_app_rotate_credentials', + description: + 'Rotate the client id and optional client-secret secret name on a shared OAuth app. Every connection on that app sees the new credentials on the next join — one write instead of updating each connection.', + keywords: [ + 'integration', + 'oauth', + 'app', + 'client', + 'credentials', + 'rotate', + 'update', + 'secret', + ], + readOnly: false, + idempotent: true, + destructive: false, + inputSchema, + outputSchema, + async handler(args, ctx: CapabilityContext) { + const user = requireMcpUser(ctx.callerContext) + const existing = await getOauthApp({ + env: ctx.env, + userId: user.userId, + slug: args.slug, + }) + if (!existing) { + throw new Error( + `OAuth app "${args.slug.trim()}" was not found for this user.`, + ) + } + const clientSecretSecretName = + args.clientSecretSecretName === undefined + ? existing.clientSecretSecretName + : args.clientSecretSecretName + const rotated = await rotateOauthAppClientCredentials({ + env: ctx.env, + userId: user.userId, + slug: args.slug, + clientId: args.clientId, + clientSecretSecretName, + }) + const joined = await listJoinedIntegrations({ + env: ctx.env, + userId: user.userId, + }) + const connections = joined + .filter(({ app }) => app.slug === rotated.slug) + .map(({ connection }) => ({ + name: connection.name, + accountLabel: connection.accountLabel, + })) + return { + app: toOauthAppPublic( + { ...rotated, connectionCount: connections.length }, + connections, + ), + } + }, + }) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index d74e46da99..b88362ffe9 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -1,83 +1,90 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' import { expect, test } from 'vitest' import { createMcpCallerContext } from '#mcp/context.ts' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { integrationDeleteCapability } from './integration-delete.ts' +import { integrationGetCapability } from './integration-get.ts' +import { integrationListCapability } from './integration-list.ts' +import { integrationOauthAppListCapability } from './integration-oauth-app-list.ts' +import { integrationOauthAppRotateCredentialsCapability } from './integration-oauth-app-rotate-credentials.ts' import { integrationSaveCapability } from './integration-save.ts' import { buildIntegrationValueName, - integrationConfigSchema, + integrationConfigWithClientIdSchema, mergeIntegrationConfig, - parseIntegrationConfig, parseIntegrationValueName, } from './integration-shared.ts' -function createValueTestDb() { - const entries = new Map() - - const db = { - prepare(query: string) { - const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLowerCase() - return { - bind(...params: Array) { - return { - async first() { - if ( - normalizedQuery.startsWith('select') && - normalizedQuery.includes('from value_buckets') - ) { - return { - id: 'bucket-1', - user_id: String(params[0]), - scope: String(params[1]), - binding_key: '', - expires_at: null, - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - } as T - } - if ( - normalizedQuery.startsWith('select') && - normalizedQuery.includes('from value_entries') && - normalizedQuery.includes('where bucket_id = ? and name = ?') - ) { - const name = String(params[1]) - const value = entries.get(name) - return value == null - ? null - : ({ - bucket_id: 'bucket-1', - name, - description: `OAuth integration config for ${name}`, - value, - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - } as T) - } - return null - }, - async run() { - if (normalizedQuery.startsWith('insert into value_entries')) { - const name = String(params[1]) - const value = String(params[3]) - entries.set(name, value) - return { meta: { changes: 1 } } - } - return { meta: { changes: 1 } } - }, - } - }, - } - }, - } as unknown as D1Database +const migrationsDirectory = new URL('../../../../migrations/', import.meta.url) + +function applyAllMigrations(db: DatabaseSync) { + for (const fileName of readdirSync(migrationsDirectory) + .filter((file) => file.endsWith('.sql')) + .sort()) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) + } +} - return { db, entries } +function createEnv() { + const sqlite = new DatabaseSync(':memory:') + applyAllMigrations(sqlite) + return { + sqlite, + env: { APP_DB: createD1FromSqlite(sqlite) } as unknown as Env, + } +} + +function caller(userId: string) { + return createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId }, + }) } -test('integration config helpers and integration_save persist validated integration records', async () => { - const current = integrationConfigSchema.parse({ +const spotifyBase = { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce' as const, + clientId: 'spotify-client-id-value', + clientSecretSecretName: null as string | null, + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email', 'playlist-read-private'], + scopeSeparator: ' ', + extraAuthorizeParams: { show_dialog: 'true' }, + }, +} + +const googleBase = { + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + clientId: 'google-client-id-value', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com', 'accounts.google.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null as string | null, + extraAuthorizeParams: { access_type: 'offline' }, + }, +} + +test('mergeIntegrationConfig and integration_save create a new app + connection with clientId shape', async () => { + const current = integrationConfigWithClientIdSchema.parse({ name: 'spotify', tokenUrl: 'https://accounts.spotify.com/api/token', apiBaseUrl: 'https://api.spotify.com/v1', flow: 'pkce', - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', @@ -114,98 +121,19 @@ test('integration config helpers and integration_save persist validated integrat requiredHosts: ['api.spotify.com'], }) - expect( - parseIntegrationConfig( - { - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com'], - }, - null, - ), - ).toMatchObject({ - name: 'spotify', - apiBaseUrl: null, + const { env } = createEnv() + const result = await integrationSaveCapability.handler(spotifyBase, { + env, + callerContext: caller('user-123'), }) - expect( - parseIntegrationConfig( - { - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com'], - authorization: { - authorizeUrl: 'ftp://accounts.spotify.com/authorize', - scopes: ['user-read-email'], - }, - }, - null, - ), - ).toBeNull() - - const testDb = createValueTestDb() - - const result = await integrationSaveCapability.handler( - { - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - apiBaseUrl: 'https://api.spotify.com/v1', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com'], - authorization: { - authorizeUrl: 'https://accounts.spotify.com/authorize', - scopes: ['user-read-email', 'playlist-read-private'], - scopeSeparator: ' ', - extraAuthorizeParams: { show_dialog: 'true' }, - }, - }, - { - env: { APP_DB: testDb.db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), - }, - ) - - expect(result.integration).toMatchObject({ - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - apiBaseUrl: 'https://api.spotify.com/v1', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com'], - authorization: { - authorizeUrl: 'https://accounts.spotify.com/authorize', - scopes: ['user-read-email', 'playlist-read-private'], - scopeSeparator: null, - extraAuthorizeParams: { show_dialog: 'true' }, - }, - }) - expect( - JSON.parse(testDb.entries.get('_integration:spotify') ?? '{}'), - ).toMatchObject({ + expect(result.integration).toEqual({ name: 'spotify', tokenUrl: 'https://accounts.spotify.com/api/token', apiBaseUrl: 'https://api.spotify.com/v1', flow: 'pkce', - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', + clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', requiredHosts: ['api.spotify.com'], @@ -216,79 +144,100 @@ test('integration config helpers and integration_save persist validated integrat extraAuthorizeParams: { show_dialog: 'true' }, }, }) + expect(result.integration).not.toHaveProperty('clientIdValueName') + expect(result.integration).not.toHaveProperty('usePkce') + + const listed = await integrationListCapability.handler( + {}, + { env, callerContext: caller('user-123') }, + ) + expect(listed.integrations).toHaveLength(1) + expect(listed.integrations[0]).toEqual(result.integration) + + const got = await integrationGetCapability.handler( + { name: 'Spotify' }, + { env, callerContext: caller('user-123') }, + ) + expect(got.integration).toEqual(result.integration) - const invalidDb = createValueTestDb() await expect( integrationSaveCapability.handler( { name: 'spotify', flow: 'pkce', - clientIdValueName: 'spotify-client-id', - }, - { - env: { APP_DB: invalidDb.db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), + clientId: 'spotify-client-id-value', }, + { env: createEnv().env, callerContext: caller('user-123') }, ), ).rejects.toThrow(/missing or invalid required fields/i) - expect(invalidDb.entries.has('_integration:spotify')).toBe(false) +}) - const upsertDb = createValueTestDb() - upsertDb.entries.set( - '_integration:spotify', - JSON.stringify({ - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - apiBaseUrl: null, - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['accounts.spotify.com', 'api.spotify.com'], - }), - ) +test('integration_save reuses an existing app when credentials match and preserves unspecified fields on partial update', async () => { + const { env } = createEnv() + const userId = 'user-reuse' - const upserted = await integrationSaveCapability.handler( - { - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - apiBaseUrl: 'https://api.spotify.com/v1', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com'], - }, + await integrationSaveCapability.handler(googleBase, { + env, + callerContext: caller(userId), + }) + await integrationSaveCapability.handler( { - env: { APP_DB: upsertDb.db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), + ...googleBase, + name: 'google-calendar', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + authorization: { + ...googleBase.authorization, + scopes: ['calendar.readonly'], + }, + requiredHosts: ['www.googleapis.com'], }, + { env, callerContext: caller(userId) }, ) - expect(upserted.integration).toMatchObject({ - name: 'spotify', - apiBaseUrl: 'https://api.spotify.com/v1', - requiredHosts: ['api.spotify.com'], + const apps = await integrationOauthAppListCapability.handler( + {}, + { env, callerContext: caller(userId) }, + ) + expect(apps.apps).toHaveLength(1) + expect(apps.apps[0]).toMatchObject({ + slug: 'google', + connectionCount: 2, + clientId: 'google-client-id-value', + clientSecretSecretName: 'googleClientSecret', }) - expect( - JSON.parse(upsertDb.entries.get('_integration:spotify') ?? '{}'), - ).toMatchObject({ - apiBaseUrl: 'https://api.spotify.com/v1', - requiredHosts: ['api.spotify.com'], + expect(apps.apps[0]?.connections.map((entry) => entry.name).sort()).toEqual([ + 'google', + 'google-calendar', + ]) + + const partial = await integrationSaveCapability.handler( + { + name: 'google', + apiBaseUrl: 'https://www.googleapis.com/v2', + }, + { env, callerContext: caller(userId) }, + ) + expect(partial.integration).toMatchObject({ + name: 'google', + apiBaseUrl: 'https://www.googleapis.com/v2', + clientId: 'google-client-id-value', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['accounts.google.com', 'www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, }) + expect(partial.integration).not.toHaveProperty('tokenExchangeStyle') }) test('integration_save persists usePkce only when it differs from the flow default', async () => { - // Canva-style: confidential flow with PKCE enabled must round-trip. - const canvaDb = createValueTestDb() + const { env: canvaEnv } = createEnv() const canvaResult = await integrationSaveCapability.handler( { name: 'canva', @@ -296,134 +245,207 @@ test('integration_save persists usePkce only when it differs from the flow defau apiBaseUrl: 'https://api.canva.com/rest/v1', flow: 'confidential', usePkce: true, - clientIdValueName: 'canva-client-id', + clientId: 'canva-client-id-value', clientSecretSecretName: 'canvaClientSecret', accessTokenSecretName: 'canvaAccessToken', refreshTokenSecretName: 'canvaRefreshToken', requiredHosts: ['api.canva.com'], tokenExchangeStyle: 'basic-form', }, - { - env: { APP_DB: canvaDb.db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), - }, + { env: canvaEnv, callerContext: caller('user-123') }, ) expect(canvaResult.integration).toMatchObject({ name: 'canva', flow: 'confidential', usePkce: true, tokenExchangeStyle: 'basic-form', - }) - expect( - JSON.parse(canvaDb.entries.get('_integration:canva') ?? '{}'), - ).toMatchObject({ - flow: 'confidential', - usePkce: true, - tokenExchangeStyle: 'basic-form', + clientId: 'canva-client-id-value', }) - // Flow-default PKCE choices normalize away instead of being stored. - const defaultDb = createValueTestDb() + const { env: defaultEnv } = createEnv() const defaultResult = await integrationSaveCapability.handler( { name: 'spotify', tokenUrl: 'https://accounts.spotify.com/api/token', flow: 'pkce', usePkce: true, - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', accessTokenSecretName: 'spotifyAccessToken', requiredHosts: ['api.spotify.com'], }, + { env: defaultEnv, callerContext: caller('user-123') }, + ) + expect(defaultResult.integration).not.toHaveProperty('usePkce') +}) + +test('integration_delete removes a connection; credential rotation updates every sibling', async () => { + const { env } = createEnv() + const userId = 'user-rotate' + + await integrationSaveCapability.handler(googleBase, { + env, + callerContext: caller(userId), + }) + await integrationSaveCapability.handler( + { + ...googleBase, + name: 'google-mail', + accessTokenSecretName: 'googleMailAccessToken', + refreshTokenSecretName: 'googleMailRefreshToken', + }, + { env, callerContext: caller(userId) }, + ) + + const rotated = await integrationOauthAppRotateCredentialsCapability.handler( { - env: { APP_DB: defaultDb.db } as unknown as Env, + slug: 'google', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + }, + { env, callerContext: caller(userId) }, + ) + expect(rotated.app).toMatchObject({ + slug: 'google', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + connectionCount: 2, + }) + expect(rotated.app).not.toHaveProperty('userId') + expect(JSON.stringify(rotated.app)).not.toMatch(/Bearer |sk_|secret_value/i) + + const google = await integrationGetCapability.handler( + { name: 'google' }, + { env, callerContext: caller(userId) }, + ) + const googleMail = await integrationGetCapability.handler( + { name: 'google-mail' }, + { env, callerContext: caller(userId) }, + ) + expect(google.integration?.clientId).toBe('google-client-id-rotated') + expect(google.integration?.clientSecretSecretName).toBe( + 'googleClientSecretRotated', + ) + expect(googleMail.integration?.clientId).toBe('google-client-id-rotated') + expect(googleMail.integration?.clientSecretSecretName).toBe( + 'googleClientSecretRotated', + ) + + const deletedMail = await integrationDeleteCapability.handler( + { name: 'google-mail' }, + { env, callerContext: caller(userId) }, + ) + expect(deletedMail).toEqual({ deleted: true }) + + const afterDelete = await integrationListCapability.handler( + {}, + { env, callerContext: caller(userId) }, + ) + expect(afterDelete.integrations.map((entry) => entry.name)).toEqual([ + 'google', + ]) + + const appsAfter = await integrationOauthAppListCapability.handler( + {}, + { env, callerContext: caller(userId) }, + ) + expect(appsAfter.apps).toHaveLength(1) + expect(appsAfter.apps[0]?.connectionCount).toBe(1) + + const deletedGoogle = await integrationDeleteCapability.handler( + { name: 'google' }, + { env, callerContext: caller(userId) }, + ) + expect(deletedGoogle).toEqual({ deleted: true }) + const emptyApps = await integrationOauthAppListCapability.handler( + {}, + { env, callerContext: caller(userId) }, + ) + expect(emptyApps.apps).toEqual([]) +}) + +test('integration capabilities deny cross-user reads and require authentication', async () => { + const { env } = createEnv() + + await integrationSaveCapability.handler(spotifyBase, { + env, + callerContext: caller('user-a'), + }) + + const otherUserList = await integrationListCapability.handler( + {}, + { env, callerContext: caller('user-b') }, + ) + expect(otherUserList.integrations).toEqual([]) + + const otherUserGet = await integrationGetCapability.handler( + { name: 'spotify' }, + { env, callerContext: caller('user-b') }, + ) + expect(otherUserGet.integration).toBeNull() + + const otherUserDelete = await integrationDeleteCapability.handler( + { name: 'spotify' }, + { env, callerContext: caller('user-b') }, + ) + expect(otherUserDelete).toEqual({ deleted: false }) + + const stillThere = await integrationGetCapability.handler( + { name: 'spotify' }, + { env, callerContext: caller('user-a') }, + ) + expect(stillThere.integration?.name).toBe('spotify') + + await expect( + integrationSaveCapability.handler(spotifyBase, { + env, callerContext: createMcpCallerContext({ baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, }), - }, - ) - expect(defaultResult.integration).not.toHaveProperty('usePkce') - expect( - JSON.parse(defaultDb.entries.get('_integration:spotify') ?? '{}'), - ).not.toHaveProperty('usePkce') + }), + ).rejects.toThrow('Authenticated MCP user is required for this capability.') }) -test('integration identity is the canonical provider key across save, lookup, and value-name parsing', async () => { - // Saving with display casing stores and returns the canonical name. - const casedDb = createValueTestDb() +test('integration identity is the canonical provider key across save and lookup', async () => { + const { env } = createEnv() const saved = await integrationSaveCapability.handler( { name: 'GitHub', tokenUrl: 'https://github.com/login/oauth/access_token', flow: 'confidential', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', requiredHosts: ['api.github.com'], }, - { - env: { APP_DB: casedDb.db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), - }, + { env, callerContext: caller('user-123') }, ) expect(saved.integration.name).toBe('github') - expect(casedDb.entries.has('_integration:github')).toBe(true) - expect(casedDb.entries.has('_integration:GitHub')).toBe(false) + expect(saved.integration.clientId).toBe('github-client-id-value') - // Every value-key derivation goes through the same canonicalization. + const got = await integrationGetCapability.handler( + { name: 'GitHub' }, + { env, callerContext: caller('user-123') }, + ) + expect(got.integration?.name).toBe('github') + + // Value-name helpers remain for concurrent migration callers. expect(buildIntegrationValueName('GitHub')).toBe('_integration:github') expect(buildIntegrationValueName('Spotify Family')).toBe( '_integration:spotify-family', ) - - // Only canonical stored keys parse as integrations. expect(parseIntegrationValueName('_integration:github')).toBe('github') expect(parseIntegrationValueName('_integration:GitHub')).toBeNull() - expect(parseIntegrationValueName('_integration:')).toBeNull() - expect(parseIntegrationValueName('value:github')).toBeNull() - // Names without any letters or numbers are rejected outright — including - // ones made only of characters the canonical form preserves (. _ -). await expect( integrationSaveCapability.handler( { name: '._-', tokenUrl: 'https://example.com/token', flow: 'pkce', - clientIdValueName: 'x-client-id', - accessTokenSecretName: 'xAccessToken', - }, - { - env: { APP_DB: createValueTestDb().db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), - }, - ), - ).rejects.toThrow(/letters or numbers/i) - await expect( - integrationSaveCapability.handler( - { - name: '!!!', - tokenUrl: 'https://example.com/token', - flow: 'pkce', - clientIdValueName: 'x-client-id', + clientId: 'x-client-id', accessTokenSecretName: 'xAccessToken', }, - { - env: { APP_DB: createValueTestDb().db } as unknown as Env, - callerContext: createMcpCallerContext({ - baseUrl: 'https://heykody.dev', - user: { userId: 'user-123' }, - }), - }, + { env: createEnv().env, callerContext: caller('user-123') }, ), ).rejects.toThrow(/letters or numbers/i) }) diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts index c81c98d6ce..ac782c4f7e 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts @@ -3,21 +3,22 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { getValue, saveValue } from '#mcp/values/service.ts' import { - buildIntegrationValueName, - integrationConfigSchema, + getIntegration, + upsertIntegration, + type IntegrationConfigWithClientId, +} from '#worker/integrations/service.ts' +import { + integrationConfigWithClientIdSchema, integrationSaveSchema, mergeIntegrationConfig, - normalizeIntegrationConfig, - parseIntegrationConfig, - parseIntegrationJson, + normalizeIntegrationConfigWithClientId, } from './integration-shared.ts' const inputSchema = integrationSaveSchema const outputSchema = z.object({ - integration: integrationConfigSchema, + integration: integrationConfigWithClientIdSchema, }) export const integrationSaveCapability = defineDomainCapability( @@ -25,7 +26,7 @@ export const integrationSaveCapability = defineDomainCapability( { name: 'integration_save', description: - 'Create or update an OAuth integration configuration for the signed-in user. Names are normalized to a canonical lowercase-kebab provider key and stored as a user-scoped value with a _integration: prefix.', + 'Create or update an OAuth integration connection for the signed-in user. Names are normalized to a canonical lowercase-kebab provider key. Partial updates merge into the existing connection; matching client credentials reuse a shared OAuth app across connections.', keywords: [ 'integration', 'oauth', @@ -34,7 +35,7 @@ export const integrationSaveCapability = defineDomainCapability( 'save', 'update', 'upsert', - 'value', + 'connection', ], readOnly: false, idempotent: true, @@ -43,56 +44,34 @@ export const integrationSaveCapability = defineDomainCapability( outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const storageContext = { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - } - const existing = await getValue({ + const existing = await getIntegration({ env: ctx.env, userId: user.userId, - name: buildIntegrationValueName(args.name), - scope: 'user', - storageContext, + name: args.name, }) - const existingIntegration = - existing == null - ? null - : parseIntegrationConfig( - parseIntegrationJson(existing.value), - args.name, - ) - const integration = existingIntegration - ? mergeIntegrationConfig(existingIntegration, args) + const config = existing + ? mergeIntegrationConfig(existing, args) : createNewIntegrationConfig(args) - const value = await saveValue({ + const integration = await upsertIntegration({ env: ctx.env, userId: user.userId, - userEmail: user.email, - name: buildIntegrationValueName(integration.name), - value: JSON.stringify(integration), - scope: 'user', - description: `OAuth integration config for ${integration.name}`, - storageContext, + config, }) - const parsed = parseIntegrationConfig( - parseIntegrationJson(value.value), - integration.name, - ) - return { - integration: parsed ?? integration, - } + return { integration } }, }, ) -function createNewIntegrationConfig(args: z.infer) { - const parsed = integrationConfigSchema.safeParse({ +function createNewIntegrationConfig( + args: z.infer, +): IntegrationConfigWithClientId { + const parsed = integrationConfigWithClientIdSchema.safeParse({ name: args.name, tokenUrl: args.tokenUrl, apiBaseUrl: args.apiBaseUrl ?? null, flow: args.flow, ...(args.usePkce !== undefined ? { usePkce: args.usePkce } : {}), - clientIdValueName: args.clientIdValueName, + clientId: args.clientId, clientSecretSecretName: args.clientSecretSecretName ?? null, accessTokenSecretName: args.accessTokenSecretName, refreshTokenSecretName: args.refreshTokenSecretName ?? null, @@ -115,5 +94,5 @@ function createNewIntegrationConfig(args: z.infer) { `Cannot create integration "${args.name}": missing or invalid required fields — ${details}`, ) } - return normalizeIntegrationConfig(parsed.data) + return normalizeIntegrationConfigWithClientId(parsed.data) } diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts index a5eb3fc666..d7f72ddc37 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts @@ -77,7 +77,7 @@ export const integrationSaveSchema = z apiBaseUrl: z.string().url().nullable().optional(), flow: z.enum(integrationFlowValues).optional(), usePkce: z.boolean().nullable().optional(), - clientIdValueName: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), clientSecretSecretName: z.string().min(1).nullable().optional(), accessTokenSecretName: z.string().min(1).optional(), refreshTokenSecretName: z.string().min(1).nullable().optional(), @@ -92,6 +92,53 @@ export type IntegrationSaveInput = z.infer export function normalizeIntegrationConfig( value: IntegrationConfig, ): IntegrationConfig { + const normalized = normalizeIntegrationConfigFields(value) + return { + ...normalized, + clientIdValueName: value.clientIdValueName.trim(), + } +} + +/** + * Flat integration config with inline `clientId` (D1 / capability shape). + * Same as {@link IntegrationConfig} except `clientIdValueName` → `clientId`. + */ +export const integrationConfigWithClientIdSchema = integrationConfigSchema + .omit({ clientIdValueName: true }) + .extend({ + clientId: z.string().min(1), + }) + +export type IntegrationConfigWithClientId = z.infer< + typeof integrationConfigWithClientIdSchema +> + +export function normalizeIntegrationConfigWithClientId( + value: IntegrationConfigWithClientId, +): IntegrationConfigWithClientId { + const normalized = normalizeIntegrationConfigFields(value) + return { + ...normalized, + clientId: value.clientId.trim(), + } +} + +function normalizeIntegrationConfigFields( + value: Pick< + IntegrationConfig, + | 'name' + | 'tokenUrl' + | 'apiBaseUrl' + | 'flow' + | 'usePkce' + | 'clientSecretSecretName' + | 'accessTokenSecretName' + | 'refreshTokenSecretName' + | 'requiredHosts' + | 'tokenExchangeStyle' + | 'authorization' + >, +) { const authorization = value.authorization ? normalizeIntegrationAuthorization(value.authorization) : null @@ -109,7 +156,6 @@ export function normalizeIntegrationConfig( apiBaseUrl: value.apiBaseUrl?.trim() || null, flow: value.flow, ...(usePkce == null ? {} : { usePkce }), - clientIdValueName: value.clientIdValueName.trim(), clientSecretSecretName: value.clientSecretSecretName?.trim() || null, accessTokenSecretName: value.accessTokenSecretName.trim(), refreshTokenSecretName: value.refreshTokenSecretName?.trim() || null, @@ -119,7 +165,7 @@ export function normalizeIntegrationConfig( } } -function normalizeIntegrationAuthorization( +export function normalizeIntegrationAuthorization( value: IntegrationAuthorization, ): IntegrationAuthorization { const scopeSeparator = @@ -147,10 +193,10 @@ function normalizeIntegrationAuthorization( } export function mergeIntegrationConfig( - current: IntegrationConfig, + current: IntegrationConfigWithClientId, update: IntegrationSaveInput, -): IntegrationConfig { - return normalizeIntegrationConfig({ +): IntegrationConfigWithClientId { + return normalizeIntegrationConfigWithClientId({ ...current, ...update, name: update.name, diff --git a/packages/worker/src/mcp/capabilities/integrations/oauth-app-shared.ts b/packages/worker/src/mcp/capabilities/integrations/oauth-app-shared.ts new file mode 100644 index 0000000000..2f203f66c2 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/integrations/oauth-app-shared.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import { + type UserOauthApp, + type UserOauthAppWithConnectionCount, +} from '#worker/integrations/types.ts' +import { + integrationFlowValues, + tokenExchangeStyleValues, +} from './integration-shared.ts' + +/** + * Public OAuth-app projection for MCP capabilities. + * Never includes client secret / token values — only secret *names*. + */ +export const oauthAppConnectionRefSchema = z.object({ + name: z.string().min(1), + accountLabel: z.string().nullable(), +}) + +export const oauthAppPublicSchema = z.object({ + slug: z.string().min(1), + provider: z.string().min(1), + label: z.string().nullable(), + clientId: z.string().min(1), + clientSecretSecretName: z.string().nullable(), + tokenUrl: z.string().url(), + authorizeUrl: z.string().url().nullable(), + apiBaseUrl: z.string().url().nullable(), + flow: z.enum(integrationFlowValues), + usePkce: z.boolean().nullable(), + tokenExchangeStyle: z.enum(tokenExchangeStyleValues).nullable(), + scopeSeparator: z.string().nullable(), + extraAuthorizeParams: z.record(z.string(), z.string()), + connectionCount: z.number().int().nonnegative(), + connections: z.array(oauthAppConnectionRefSchema), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), +}) + +export type OauthAppPublic = z.infer + +export function toOauthAppPublic( + app: UserOauthApp | UserOauthAppWithConnectionCount, + connections: Array<{ name: string; accountLabel: string | null }>, +): OauthAppPublic { + const connectionCount = + 'connectionCount' in app ? app.connectionCount : connections.length + return { + slug: app.slug, + provider: app.provider, + label: app.label, + clientId: app.clientId, + clientSecretSecretName: app.clientSecretSecretName, + tokenUrl: app.tokenUrl, + authorizeUrl: app.authorizeUrl, + apiBaseUrl: app.apiBaseUrl, + flow: app.flow, + usePkce: app.usePkce, + tokenExchangeStyle: app.tokenExchangeStyle, + scopeSeparator: app.scopeSeparator, + extraAuthorizeParams: app.extraAuthorizeParams, + connectionCount, + connections, + createdAt: app.createdAt, + updatedAt: app.updatedAt, + } +} From 14bdda43bc5fb347b068c0917e0f8b461cdaf10d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:30 +0000 Subject: [PATCH 03/15] refactor(search): build integration entities from D1 instead of value rows The integration search entity derived candidates by scanning every user value and JSON-parsing the ones with an _integration: prefix. It now queries the integrations service directly. Entity type, {name}:integration ref format, and the indexed document field set are unchanged, so ranking and agent-facing behavior do not move. Co-authored-by: Kent C. Dodds --- .../mcp/capabilities/meta/search.node.test.ts | 6 + ...tegration-package-suggestions.node.test.ts | 2 +- .../tools/integration-package-suggestions.ts | 4 +- packages/worker/src/mcp/tools/search-core.ts | 10 +- .../src/mcp/tools/search-descriptors.ts | 2 +- .../src/mcp/tools/search-detail.node.test.ts | 125 +++++++ .../worker/src/mcp/tools/search-detail.ts | 51 +-- .../src/mcp/tools/search-entity-plugin.ts | 4 +- .../search-entity-plugins/integration.ts | 159 +++++---- .../tools/search-entity-registry.node.test.ts | 171 +++++++--- .../src/mcp/tools/search-format-types.ts | 19 +- .../src/mcp/tools/search-format.node.test.ts | 33 +- .../src/mcp/tools/search-handler.node.test.ts | 69 +++- .../worker/src/mcp/tools/search-loaders.ts | 18 +- packages/worker/src/mcp/tools/search-types.ts | 2 + .../worker/src/mcp/tools/search.node.test.ts | 318 ++++++++++++++++-- 16 files changed, 743 insertions(+), 250 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/meta/search.node.test.ts b/packages/worker/src/mcp/capabilities/meta/search.node.test.ts index 5bda099554..af5e04fea5 100644 --- a/packages/worker/src/mcp/capabilities/meta/search.node.test.ts +++ b/packages/worker/src/mcp/capabilities/meta/search.node.test.ts @@ -23,6 +23,7 @@ const mockModule = vi.hoisted(() => ({ listSavedPackagesByUserId: vi.fn(async () => []), listUserSecretsForSearch: vi.fn(async () => []), listValues: vi.fn(async () => []), + listJoinedIntegrations: vi.fn(async () => []), loadRelevantMemoriesForTool: vi.fn(async () => null), acknowledgeToolMemories: vi.fn(async () => undefined), buildMemoryRetrievalQuery: vi.fn( @@ -69,6 +70,11 @@ vi.mock('#mcp/values/service.ts', () => ({ listValues: (...args: Array) => mockModule.listValues(...args), })) +vi.mock('#worker/integrations/service.ts', () => ({ + listJoinedIntegrations: (...args: Array) => + mockModule.listJoinedIntegrations(...args), +})) + vi.mock('#mcp/tools/memory-tool-context.ts', () => ({ loadRelevantMemoriesForTool: (...args: Array) => mockModule.loadRelevantMemoriesForTool(...args), diff --git a/packages/worker/src/mcp/tools/integration-package-suggestions.node.test.ts b/packages/worker/src/mcp/tools/integration-package-suggestions.node.test.ts index 9b597f5d5e..ff5804c97e 100644 --- a/packages/worker/src/mcp/tools/integration-package-suggestions.node.test.ts +++ b/packages/worker/src/mcp/tools/integration-package-suggestions.node.test.ts @@ -37,7 +37,7 @@ function createIntegration(name: string) { tokenUrl: 'https://oauth2.googleapis.com/token', apiBaseUrl: 'https://www.googleapis.com/calendar/v3', flow: 'confidential' as const, - clientIdValueName: `${name}-client-id`, + clientId: `${name}-client-id-value`, clientSecretSecretName: `${name}ClientSecret`, accessTokenSecretName: `${name}AccessToken`, refreshTokenSecretName: `${name}RefreshToken`, diff --git a/packages/worker/src/mcp/tools/integration-package-suggestions.ts b/packages/worker/src/mcp/tools/integration-package-suggestions.ts index 1bc0de7410..f47b11937b 100644 --- a/packages/worker/src/mcp/tools/integration-package-suggestions.ts +++ b/packages/worker/src/mcp/tools/integration-package-suggestions.ts @@ -2,7 +2,7 @@ import { buildCommunityPublicUrl } from '#mcp/capabilities/community/shared.ts' import { getDomain } from 'tldts' import { canonicalIntegrationName, - type IntegrationConfig, + type IntegrationConfigWithClientId, } from '#mcp/capabilities/integrations/integration-shared.ts' import { searchCommunityListings } from '#worker/community/service.ts' import { type RelatedIntegrationPackageSuggestion } from './search-format.ts' @@ -12,7 +12,7 @@ export const maxIntegrationPackageSuggestions = 3 const communitySuggestionCandidateLimit = 12 type IntegrationProviderMetadata = Pick< - IntegrationConfig, + IntegrationConfigWithClientId, 'name' | 'tokenUrl' | 'apiBaseUrl' | 'requiredHosts' | 'authorization' > diff --git a/packages/worker/src/mcp/tools/search-core.ts b/packages/worker/src/mcp/tools/search-core.ts index b384d21a17..1885c27c92 100644 --- a/packages/worker/src/mcp/tools/search-core.ts +++ b/packages/worker/src/mcp/tools/search-core.ts @@ -163,7 +163,7 @@ export async function searchUnified(input: { registry: Awaited> optionalRows: Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > retrieverResults?: Array /** Optional capability domain id; scopes ranked results to that domain's capabilities. */ @@ -188,7 +188,12 @@ export async function searchUnified(input: { // Domain scoping is a capability-graph drill-down; user-owned entities // (packages, values, integrations, secrets, retrievers) have no domain. const optionalRows = domainFilter - ? { packageRows: [], userSecretRows: [], userValueRows: [] } + ? { + packageRows: [], + userSecretRows: [], + userValueRows: [], + userIntegrationRows: [], + } : input.optionalRows const retrieverResults = domainFilter ? [] : (input.retrieverResults ?? []) if (!query) { @@ -381,6 +386,7 @@ export async function searchPackages(input: { packageRows: input.rows, userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) return { diff --git a/packages/worker/src/mcp/tools/search-descriptors.ts b/packages/worker/src/mcp/tools/search-descriptors.ts index 8de890afdc..202d91697f 100644 --- a/packages/worker/src/mcp/tools/search-descriptors.ts +++ b/packages/worker/src/mcp/tools/search-descriptors.ts @@ -92,7 +92,7 @@ export function buildSearchableEntityDescriptors(input: { registry: Awaited> optionalRows: Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > }): Array { const descriptors: Array = [] diff --git a/packages/worker/src/mcp/tools/search-detail.node.test.ts b/packages/worker/src/mcp/tools/search-detail.node.test.ts index fa6a71d124..38ca73b090 100644 --- a/packages/worker/src/mcp/tools/search-detail.node.test.ts +++ b/packages/worker/src/mcp/tools/search-detail.node.test.ts @@ -2,11 +2,14 @@ import { expect, test, vi } from 'vitest' import { McpCallerError } from '#mcp/caller-error.ts' import { createMcpCallerContext } from '#mcp/context.ts' import { type McpRegistrationAgent } from '#mcp/mcp-registration-agent.ts' +import type * as IntegrationsService from '#worker/integrations/service.ts' +import { type JoinedIntegration } from '#worker/integrations/types.ts' const mockModule = vi.hoisted(() => ({ getSavedPackageById: vi.fn(), getSavedPackageByKodyId: vi.fn(), getValue: vi.fn(), + getJoinedIntegration: vi.fn(), loadPackageSourceBySourceId: vi.fn(), collectIntegrationPackageSuggestions: vi.fn(), })) @@ -27,6 +30,17 @@ vi.mock('#mcp/values/service.ts', () => ({ getValue: (...args: Array) => mockModule.getValue(...args), })) +vi.mock('#worker/integrations/service.ts', async () => { + const actual = await vi.importActual( + '#worker/integrations/service.ts', + ) + return { + ...actual, + getJoinedIntegration: (...args: Array) => + mockModule.getJoinedIntegration(...args), + } +}) + vi.mock('./integration-package-suggestions.ts', () => ({ collectIntegrationPackageSuggestions: (...args: Array) => mockModule.collectIntegrationPackageSuggestions(...args), @@ -53,14 +67,57 @@ function emptySearchRows() { return { userValueRows: [], userSecretRows: [], + userIntegrationRows: [], packageRows: [], registry: { capabilitySpecs: {} }, + warnings: [], + } +} + +function createJoinedIntegration(name: string): JoinedIntegration { + const now = '2026-01-01T00:00:00.000Z' + return { + app: { + userId: 'user-1', + slug: name, + provider: name, + label: null, + clientId: `${name}-client-id-value`, + clientSecretSecretName: `${name}-client-secret`, + tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: now, + updatedAt: now, + }, + connection: { + userId: 'user-1', + name, + appSlug: name, + accountLabel: null, + description: `${name} OAuth integration`, + scopes: ['repo'], + requiredHosts: ['api.github.com'], + accessTokenSecretName: `${name}-access-token`, + refreshTokenSecretName: null, + connectedAt: null, + tokenRefreshedAt: null, + createdAt: now, + updatedAt: now, + }, } } test('resolveEntityDetail reports unresolvable entity refs as caller errors', async () => { mockModule.getValue.mockReset() mockModule.getValue.mockResolvedValue(null) + mockModule.getJoinedIntegration.mockReset() + mockModule.getJoinedIntegration.mockResolvedValue(null) const agent = createAgent() const callerContext = agent.getCallerContext() @@ -100,3 +157,71 @@ test('resolveEntityDetail reports unresolvable entity refs as caller errors', as await expect(detail).rejects.toThrow(message) } }) + +test('resolveEntityDetail loads {name}:integration via getJoinedIntegration', async () => { + const joined = createJoinedIntegration('github') + mockModule.getJoinedIntegration.mockReset() + mockModule.getJoinedIntegration.mockResolvedValue(joined) + mockModule.collectIntegrationPackageSuggestions.mockReset() + mockModule.collectIntegrationPackageSuggestions.mockResolvedValue([]) + + const agent = createAgent() + const detail = await resolveEntityDetail({ + agent, + callerContext: agent.getCallerContext(), + userId: 'user-1', + username: 'user', + entity: 'github:integration', + searchRows: emptySearchRows() as never, + }) + + expect(mockModule.getJoinedIntegration).toHaveBeenCalledWith({ + env: { APP_DB: {} }, + userId: 'user-1', + name: 'github', + }) + expect(detail).toMatchObject({ + type: 'integration', + id: 'github', + title: 'github', + description: 'github OAuth integration', + config: { + name: 'github', + clientId: 'github-client-id-value', + clientSecretSecretName: 'github-client-secret', + accessTokenSecretName: 'github-access-token', + tokenUrl: 'https://github.com/login/oauth/access_token', + }, + }) + expect(detail).not.toHaveProperty('row') + expect(JSON.stringify(detail)).not.toContain('secret-value') +}) + +test('resolveEntityDetail keeps integrations isolated by userId', async () => { + mockModule.getJoinedIntegration.mockReset() + mockModule.getJoinedIntegration.mockImplementation( + async (input: { userId: string; name: string }) => { + if (input.userId !== 'user-1' || input.name !== 'github') return null + return createJoinedIntegration('github') + }, + ) + mockModule.collectIntegrationPackageSuggestions.mockResolvedValue([]) + + const agent = createAgent() + await expect( + resolveEntityDetail({ + agent, + callerContext: agent.getCallerContext(), + userId: 'user-2', + username: 'other', + entity: 'github:integration', + searchRows: emptySearchRows() as never, + }), + ).rejects.toThrow('Saved integration not found for this user.') + + expect(mockModule.getJoinedIntegration).toHaveBeenCalledWith({ + env: { APP_DB: {} }, + userId: 'user-2', + name: 'github', + }) +}) diff --git a/packages/worker/src/mcp/tools/search-detail.ts b/packages/worker/src/mcp/tools/search-detail.ts index 233858242d..f2f8ef2b5b 100644 --- a/packages/worker/src/mcp/tools/search-detail.ts +++ b/packages/worker/src/mcp/tools/search-detail.ts @@ -1,10 +1,5 @@ import { buildPackageAppUrl } from '@kody-internal/shared/public-urls.ts' import { McpCallerError } from '#mcp/caller-error.ts' -import { - parseIntegrationConfig, - parseIntegrationJson, - parseIntegrationValueName, -} from '#mcp/capabilities/integrations/integration-shared.ts' import { type McpRegistrationAgent } from '#mcp/mcp-registration-agent.ts' import { buildValueEntityId, @@ -12,7 +7,10 @@ import { parseValueEntityId, } from '#mcp/tools/search-entities.ts' import { getValue } from '#mcp/values/service.ts' -import { type ValueMetadata } from '#mcp/values/types.ts' +import { + getJoinedIntegration, + toIntegrationConfig, +} from '#worker/integrations/service.ts' import { getSavedPackageById, getSavedPackageByKodyId, @@ -24,22 +22,6 @@ import { parseEntityRef } from './search-format.ts' import { collectRelatedCapabilityOperations } from './search-related-capabilities.ts' import { type SearchRowsAndRegistry } from './search-types.ts' -function findIntegrationDetail( - rows: Array, - integrationName: string, -) { - for (const row of rows) { - if (parseIntegrationValueName(row.name) !== integrationName) continue - const config = parseIntegrationConfig( - parseIntegrationJson(row.value), - integrationName, - ) - if (!config) continue - return { row, config } - } - return null -} - export async function resolveEntityDetail(input: { agent: McpRegistrationAgent callerContext: ReturnType @@ -144,29 +126,30 @@ export async function resolveEntityDetail(input: { } if (ref.type === 'integration') { - const integration = findIntegrationDetail( - input.searchRows.userValueRows, - ref.id, - ) - if (!integration) { + const joined = await getJoinedIntegration({ + env: input.agent.getEnv(), + userId: input.userId, + name: ref.id, + }) + if (!joined) { throw new McpCallerError('Saved integration not found for this user.') } + const config = toIntegrationConfig(joined.app, joined.connection) const relatedPackageSuggestions = await collectIntegrationPackageSuggestions({ env: input.agent.getEnv(), baseUrl: input.callerContext.baseUrl, - integration: integration.config, + integration: config, packageRows: input.searchRows.packageRows, }) return { type: 'integration' as const, - id: integration.config.name, - title: integration.config.name, + id: config.name, + title: config.name, description: - integration.row.description.trim() || - `Saved OAuth integration configuration (${integration.config.flow} flow).`, - row: integration.row, - config: integration.config, + joined.connection.description.trim() || + `Saved OAuth integration configuration (${config.flow} flow).`, + config, ...(relatedPackageSuggestions.length > 0 ? { relatedPackageSuggestions } : {}), diff --git a/packages/worker/src/mcp/tools/search-entity-plugin.ts b/packages/worker/src/mcp/tools/search-entity-plugin.ts index 279ec9d2f8..8bd08caddb 100644 --- a/packages/worker/src/mcp/tools/search-entity-plugin.ts +++ b/packages/worker/src/mcp/tools/search-entity-plugin.ts @@ -24,7 +24,7 @@ export type SearchEntityCandidateInput = { registry: Awaited> optionalRows: Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > retrieverResults: Array queryEmbedding: ReadonlyArray @@ -35,7 +35,7 @@ export type SearchEntityDescriptorInput = { registry: Awaited> optionalRows: Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > } diff --git a/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts b/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts index 742a235d61..85321226d4 100644 --- a/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts +++ b/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts @@ -3,11 +3,8 @@ import { deterministicEmbedding, lexicalScore, } from '#mcp/capabilities/capability-search.ts' -import { - parseIntegrationConfig, - parseIntegrationJson, - parseIntegrationValueName, -} from '#mcp/capabilities/integrations/integration-shared.ts' +import { type IntegrationConfigWithClientId } from '#mcp/capabilities/integrations/integration-shared.ts' +import { toIntegrationConfig } from '#worker/integrations/service.ts' import { type SearchEntityPlugin } from '../search-entity-plugin.ts' import { @@ -22,10 +19,20 @@ import { import { buildCandidateBaseScore } from '../search-scoring.ts' import { extractSearchTokens } from '../understand-search-query.ts' +function describeIntegration(input: { + description: string + flow: IntegrationConfigWithClientId['flow'] +}) { + return ( + input.description.trim() || + `Saved OAuth integration configuration (${input.flow} flow).` + ) +} + export function buildIntegrationSearchDocument(input: { integrationName: string description: string - config: NonNullable> + config: IntegrationConfigWithClientId }): string { return [ input.integrationName, @@ -43,46 +50,38 @@ export function buildIntegrationSearchDocument(input: { export const integrationSearchEntityPlugin = { type: 'integration', - buildValueRowDescriptor(row) { - const integrationName = parseIntegrationValueName(row.name) - if (!integrationName) return undefined - const config = parseIntegrationConfig( - parseIntegrationJson(row.value), - integrationName, - ) - if (!config) return undefined - return { - type: 'integration', - id: integrationName, - title: integrationName, - primaryAliases: [integrationName], - secondaryAliases: [ - row.description, - config.apiBaseUrl ?? '', - config.tokenUrl, - config.flow, - ], - tertiaryAliases: [ - ...(config.requiredHosts ?? []), - ...(config.apiBaseUrl ? extractSearchTokens(config.apiBaseUrl) : []), - ], - } + buildDescriptors(input) { + return input.optionalRows.userIntegrationRows.map(({ app, connection }) => { + const config = toIntegrationConfig(app, connection) + return { + type: 'integration' as const, + id: connection.name, + title: connection.name, + primaryAliases: [connection.name], + secondaryAliases: [ + connection.description, + config.apiBaseUrl ?? '', + config.tokenUrl, + config.flow, + ], + tertiaryAliases: [ + ...(config.requiredHosts ?? []), + ...(config.apiBaseUrl ? extractSearchTokens(config.apiBaseUrl) : []), + ], + } + }) }, buildCandidates(input) { - return input.optionalRows.userValueRows - .flatMap((row) => { - const integrationName = parseIntegrationValueName(row.name) - if (!integrationName) return [] - const config = parseIntegrationConfig( - parseIntegrationJson(row.value), - integrationName, - ) - if (!config) return [] + return input.optionalRows.userIntegrationRows + .map(({ app, connection }) => { + const config = toIntegrationConfig(app, connection) + const description = describeIntegration({ + description: connection.description, + flow: config.flow, + }) const document = buildIntegrationSearchDocument({ - integrationName, - description: - row.description.trim() || - `Saved OAuth integration configuration (${config.flow} flow).`, + integrationName: connection.name, + description, config, }) const lexical = lexicalScore(input.query, document) @@ -90,44 +89,40 @@ export const integrationSearchEntityPlugin = { input.queryEmbedding, deterministicEmbedding(document), ) - return [ - { - match: { - type: 'integration' as const, - integrationName, - title: integrationName, - description: - row.description.trim() || - `Saved OAuth integration configuration (${config.flow} flow).`, - flow: config.flow, - tokenUrl: config.tokenUrl, - apiBaseUrl: config.apiBaseUrl ?? null, - requiredHosts: config.requiredHosts ?? [], - clientIdValueName: config.clientIdValueName, - clientSecretSecretName: config.clientSecretSecretName ?? null, - accessTokenSecretName: config.accessTokenSecretName, - refreshTokenSecretName: config.refreshTokenSecretName ?? null, - authorization: config.authorization ?? null, - }, + return { + match: { type: 'integration' as const, - id: integrationName, - title: integrationName, - searchFields: [ - integrationName, - row.description, - config.flow, - config.apiBaseUrl ?? '', - config.tokenUrl, - config.authorization?.authorizeUrl ?? '', - ...(config.authorization?.scopes ?? []), - ...(config.requiredHosts ?? []), - ], - scoreComponents: buildCandidateBaseScore({ - lexical, - vector, - }), + integrationName: connection.name, + title: connection.name, + description, + flow: config.flow, + tokenUrl: config.tokenUrl, + apiBaseUrl: config.apiBaseUrl ?? null, + requiredHosts: config.requiredHosts ?? [], + clientId: config.clientId, + clientSecretSecretName: config.clientSecretSecretName ?? null, + accessTokenSecretName: config.accessTokenSecretName, + refreshTokenSecretName: config.refreshTokenSecretName ?? null, + authorization: config.authorization ?? null, }, - ] + type: 'integration' as const, + id: connection.name, + title: connection.name, + searchFields: [ + connection.name, + connection.description, + config.flow, + config.apiBaseUrl ?? '', + config.tokenUrl, + config.authorization?.authorizeUrl ?? '', + ...(config.authorization?.scopes ?? []), + ...(config.requiredHosts ?? []), + ], + scoreComponents: buildCandidateBaseScore({ + lexical, + vector, + }), + } }) .filter((candidate) => candidate.scoreComponents.base > 0) }, @@ -144,7 +139,7 @@ export const integrationSearchEntityPlugin = { tokenUrl: match.tokenUrl, apiBaseUrl: match.apiBaseUrl, requiredHosts: match.requiredHosts, - clientIdValueName: match.clientIdValueName, + clientId: match.clientId, clientSecretSecretName: match.clientSecretSecretName, accessTokenSecretName: match.accessTokenSecretName, refreshTokenSecretName: match.refreshTokenSecretName, @@ -178,7 +173,7 @@ export const integrationSearchEntityPlugin = { '', '## Related stored names', '', - `- Client ID value name: \`${detail.config.clientIdValueName}\``, + `- Client ID: \`${detail.config.clientId}\``, `- Client secret secret name: ${detail.config.clientSecretSecretName ? `\`${detail.config.clientSecretSecretName}\`` : 'none'}`, `- Access token secret name: \`${detail.config.accessTokenSecretName}\``, `- Refresh token secret name: ${detail.config.refreshTokenSecretName ? `\`${detail.config.refreshTokenSecretName}\`` : 'none'}`, @@ -236,7 +231,7 @@ export const integrationSearchEntityPlugin = { flow: detail.config.flow, tokenUrl: detail.config.tokenUrl, apiBaseUrl: detail.config.apiBaseUrl ?? null, - clientIdValueName: detail.config.clientIdValueName, + clientId: detail.config.clientId, clientSecretSecretName: detail.config.clientSecretSecretName ?? null, accessTokenSecretName: detail.config.accessTokenSecretName, refreshTokenSecretName: detail.config.refreshTokenSecretName ?? null, diff --git a/packages/worker/src/mcp/tools/search-entity-registry.node.test.ts b/packages/worker/src/mcp/tools/search-entity-registry.node.test.ts index 7d2daf7ab7..461284f592 100644 --- a/packages/worker/src/mcp/tools/search-entity-registry.node.test.ts +++ b/packages/worker/src/mcp/tools/search-entity-registry.node.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { buildIntegrationValueName } from '#mcp/capabilities/integrations/integration-shared.ts' +import { type JoinedIntegration } from '#worker/integrations/types.ts' import { searchEntityPlugins } from './search-entity-registry.ts' import { buildSearchableEntityDescriptors } from './search-descriptors.ts' @@ -47,6 +47,54 @@ function createPackageRow(): PackageSearchRow { } } +function createJoinedIntegration(input: { + userId?: string + name: string + description?: string + scopes?: Array + requiredHosts?: Array + appSlug?: string +}): JoinedIntegration { + const userId = input.userId ?? 'user-1' + const appSlug = input.appSlug ?? input.name + const now = '2026-01-01T00:00:00.000Z' + return { + app: { + userId, + slug: appSlug, + provider: appSlug.split('-')[0] ?? appSlug, + label: null, + clientId: `${input.name}_client_id`, + clientSecretSecretName: `${input.name}_client_secret`, + tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: now, + updatedAt: now, + }, + connection: { + userId, + name: input.name, + appSlug, + accountLabel: null, + description: input.description ?? `${input.name} integration`, + scopes: input.scopes ?? [], + requiredHosts: input.requiredHosts ?? ['api.github.com'], + accessTokenSecretName: `${input.name}_access_token`, + refreshTokenSecretName: null, + connectedAt: null, + tokenRefreshedAt: null, + createdAt: now, + updatedAt: now, + }, + } +} + test('descriptor seam follows registry order and preserves value-backed affinity', () => { expect(searchEntityPlugins.map((plugin) => plugin.type)).toEqual([ 'capability', @@ -85,25 +133,12 @@ test('descriptor seam follows registry order and preserves value-backed affinity createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, - { - userId: 'user-1', - name: buildIntegrationValueName('GitHub'), - value: JSON.stringify({ - name: 'github', - tokenUrl: 'https://github.com/login/oauth/access_token', - apiBaseUrl: 'https://api.github.com', - flow: 'confidential', - clientIdValueName: 'github_client_id', - clientSecretSecretName: 'github_client_secret', - accessTokenSecretName: 'github_access_token', - }), + ], + userIntegrationRows: [ + createJoinedIntegration({ + name: 'github', description: 'GitHub integration', - scope: 'user', - appId: null, - ttlMs: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, + }), ], userSecretRows: [ { @@ -131,50 +166,43 @@ test('descriptor seam follows registry order and preserves value-backed affinity 'weather_api_key', ]) - const userValueRows = [ - { - userId: 'user-1', - name: buildIntegrationValueName('github'), - value: JSON.stringify({ - name: 'github', - tokenUrl: 'https://github.com/login/oauth/access_token', - apiBaseUrl: 'https://api.github.com', - flow: 'confidential', - clientIdValueName: 'github_client_id', - clientSecretSecretName: 'github_client_secret', - accessTokenSecretName: 'github_access_token', - }), + const userIntegrationRows = [ + createJoinedIntegration({ + name: 'github', description: 'GitHub integration', - scope: 'user' as const, - appId: null, - ttlMs: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - ...Array.from({ length: 8 }, (_, index) => ({ - userId: 'user-1', - name: `github_value_${index}`, - value: `github value ${index}`, - description: 'GitHub preference', - scope: 'user' as const, - appId: null, - ttlMs: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - })), - ] satisfies OptionalSearchRowsResult['userValueRows'] + }), + ] satisfies OptionalSearchRowsResult['userIntegrationRows'] + const userValueRows = Array.from({ length: 8 }, (_, index) => ({ + userId: 'user-1', + name: `github_value_${index}`, + value: `github value ${index}`, + description: 'GitHub preference', + scope: 'user' as const, + appId: null, + ttlMs: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + })) satisfies OptionalSearchRowsResult['userValueRows'] const affinityDescriptors = buildSearchableEntityDescriptors({ registry: { capabilitySpecs: {} } as never, optionalRows: { packageRows: [], userSecretRows: [], userValueRows, + userIntegrationRows, }, }) expect(affinityDescriptors.map((descriptor) => descriptor.type)).toEqual([ + 'value', + 'value', + 'value', + 'value', + 'value', + 'value', + 'value', + 'value', 'integration', - ...Array.from({ length: 8 }, () => 'value'), ]) const intent = understandSearchQuery({ @@ -187,3 +215,44 @@ test('descriptor seam follows registry order and preserves value-backed affinity id: 'github', }) }) + +test('value search descriptors exclude platform-reserved openapi bindings', () => { + const descriptors = buildSearchableEntityDescriptors({ + registry: { capabilitySpecs: {} } as never, + optionalRows: { + packageRows: [], + userSecretRows: [], + userValueRows: [ + { + userId: 'user-1', + name: '_openapi:widgets', + value: '{"provider":"widgets"}', + description: 'Widgets OpenAPI binding', + scope: 'user', + appId: null, + ttlMs: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + userId: 'user-1', + name: 'preferred_repo', + value: 'kentcdodds/kody', + description: 'Preferred repo', + scope: 'user', + appId: null, + ttlMs: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + userIntegrationRows: [], + }, + }) + + expect(descriptors.map((descriptor) => descriptor.type)).toEqual(['value']) + expect(descriptors[0]).toMatchObject({ + type: 'value', + id: 'user:preferred_repo', + }) +}) diff --git a/packages/worker/src/mcp/tools/search-format-types.ts b/packages/worker/src/mcp/tools/search-format-types.ts index 4782a1813d..dc24e11e57 100644 --- a/packages/worker/src/mcp/tools/search-format-types.ts +++ b/packages/worker/src/mcp/tools/search-format-types.ts @@ -1,4 +1,4 @@ -import { type IntegrationConfig } from '#mcp/capabilities/integrations/integration-shared.ts' +import { type IntegrationConfigWithClientId } from '#mcp/capabilities/integrations/integration-shared.ts' import { type CapabilitySpec } from '#mcp/capabilities/types.ts' import { type SecretSearchRow } from '#mcp/secrets/types.ts' import { type ValueMetadata } from '#mcp/values/types.ts' @@ -222,11 +222,11 @@ export type SlimSearchMatch = tokenUrl: string apiBaseUrl: string | null requiredHosts: Array - clientIdValueName: string + clientId: string clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null - authorization: IntegrationConfig['authorization'] | null + authorization: IntegrationConfigWithClientId['authorization'] | null nextStep?: string } | { @@ -369,15 +369,15 @@ export type SearchEntityDetailStructured = title: string description: string usage: string - flow: IntegrationConfig['flow'] + flow: IntegrationConfigWithClientId['flow'] tokenUrl: string apiBaseUrl: string | null - clientIdValueName: string + clientId: string clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null requiredHosts: Array - authorization: IntegrationConfig['authorization'] | null + authorization: IntegrationConfigWithClientId['authorization'] | null relatedPackageSuggestions?: Array } @@ -421,8 +421,7 @@ export type SearchEntityDetail = id: string title: string description: string - row: ValueMetadata - config: IntegrationConfig + config: IntegrationConfigWithClientId relatedPackageSuggestions?: Array } @@ -481,11 +480,11 @@ export type SearchMatch = tokenUrl: string apiBaseUrl: string | null requiredHosts: Array - clientIdValueName: string + clientId: string clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null - authorization?: IntegrationConfig['authorization'] | null + authorization?: IntegrationConfigWithClientId['authorization'] | null } | { type: 'secret' diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index 1796464e3e..e89cdf4d10 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -114,7 +114,7 @@ test('search formatting keeps entity refs and generates safe, runnable usage sni flow: 'confidential', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', - clientIdValueName: 'github_client_id', + clientId: 'github_client_id', clientSecretSecretName: 'github_client_secret', accessTokenSecretName: 'github_access_token', refreshTokenSecretName: 'github_refresh_token', @@ -144,7 +144,7 @@ test('search formatting keeps entity refs and generates safe, runnable usage sni tokenUrl: 'https://example.com/token', apiBaseUrl: 'https://example.com/api', requiredHosts: ['example.com'], - clientIdValueName: 'client-id', + clientId: 'client-id', clientSecretSecretName: 'client-secret', accessTokenSecretName: 'access-token', refreshTokenSecretName: 'refresh-token', @@ -231,22 +231,12 @@ test('search formatting keeps entity refs and generates safe, runnable usage sni id: 'github', title: 'github', description: 'GitHub OAuth integration config', - row: { - name: '_integration:github', - scope: 'user', - value: '{}', - description: 'GitHub OAuth integration config', - appId: null, - createdAt: '2026-03-20T00:00:00.000Z', - updatedAt: '2026-03-20T00:00:00.000Z', - ttlMs: null, - }, config: { name: 'github', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'confidential', - clientIdValueName: 'github_client_id', + clientId: 'github_client_id', clientSecretSecretName: 'github_client_secret', accessTokenSecretName: 'github_access_token', refreshTokenSecretName: null, @@ -275,6 +265,7 @@ test('search formatting keeps entity refs and generates safe, runnable usage sni expect(integrationDetail.structured).toMatchObject({ type: 'integration', entityRef: 'github:integration', + clientId: 'github_client_id', relatedPackageSuggestions: [ expect.objectContaining({ source: 'user', @@ -289,28 +280,20 @@ test('search formatting keeps entity refs and generates safe, runnable usage sni }) expect(integrationDetail.markdown).toContain('github:package') expect(integrationDetail.markdown).toContain('listing-1') + expect(integrationDetail.markdown).toContain('Client ID: `github_client_id`') + expect(integrationDetail.markdown).not.toContain('Client ID value name') const leanIntegrationDetail = formatEntityDetailMarkdown({ type: 'integration', id: 'github', title: 'github', description: 'GitHub OAuth integration config', - row: { - name: '_integration:github', - scope: 'user', - value: '{}', - description: 'GitHub OAuth integration config', - appId: null, - createdAt: '2026-03-20T00:00:00.000Z', - updatedAt: '2026-03-20T00:00:00.000Z', - ttlMs: null, - }, config: { name: 'github', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'confidential', - clientIdValueName: 'github_client_id', + clientId: 'github_client_id', clientSecretSecretName: 'github_client_secret', accessTokenSecretName: 'github_access_token', refreshTokenSecretName: null, @@ -846,7 +829,7 @@ test('search markdown summarizes broad results safely and only suggests entity d tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', requiredHosts: ['api.github.com'], - clientIdValueName: 'github-client-id', + clientId: 'github-client-id', clientSecretSecretName: 'github-client-secret', accessTokenSecretName: 'github-access-token', refreshTokenSecretName: 'github-refresh-token', diff --git a/packages/worker/src/mcp/tools/search-handler.node.test.ts b/packages/worker/src/mcp/tools/search-handler.node.test.ts index 8fcc672e40..216c2a1901 100644 --- a/packages/worker/src/mcp/tools/search-handler.node.test.ts +++ b/packages/worker/src/mcp/tools/search-handler.node.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest' +import type * as IntegrationsService from '#worker/integrations/service.ts' import { consoleWarn } from '#worker/test-support/console-spies.ts' const mockModule = vi.hoisted(() => ({ @@ -24,6 +25,8 @@ const mockModule = vi.hoisted(() => ({ listSavedPackagesByUserId: vi.fn(async () => []), listUserSecretsForSearch: vi.fn(async () => []), listValues: vi.fn(async () => []), + listJoinedIntegrations: vi.fn(async () => []), + getJoinedIntegration: vi.fn(async () => null), loadPackageSourceBySourceId: vi.fn(), loadRelevantMemoriesForTool: vi.fn(async () => null), acknowledgeToolMemories: vi.fn(async () => undefined), @@ -72,6 +75,19 @@ vi.mock('#mcp/values/service.ts', () => ({ listValues: (...args: Array) => mockModule.listValues(...args), })) +vi.mock('#worker/integrations/service.ts', async () => { + const actual = await vi.importActual( + '#worker/integrations/service.ts', + ) + return { + ...actual, + listJoinedIntegrations: (...args: Array) => + mockModule.listJoinedIntegrations(...args), + getJoinedIntegration: (...args: Array) => + mockModule.getJoinedIntegration(...args), + } +}) + vi.mock('./memory-tool-context.ts', async () => { const actual = await vi.importActual('./memory-tool-context.ts') return { @@ -765,28 +781,47 @@ test('integration entity detail enriches related packages without bloating ranke displayName: 'User', username: 'user', } - const githubIntegrationValue = { - name: '_integration:github', - scope: 'user' as const, - value: JSON.stringify({ + const now = '2026-01-01T00:00:00.000Z' + const githubJoinedIntegration = { + app: { + userId: 'user-1', + slug: 'github', + provider: 'github', + label: null, + clientId: 'github-client-id-value', + clientSecretSecretName: 'github-client-secret', tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', apiBaseUrl: 'https://api.github.com', - flow: 'confidential', - clientIdValueName: 'github-client-id', - clientSecretSecretName: 'github-client-secret', + flow: 'confidential' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: now, + updatedAt: now, + }, + connection: { + userId: 'user-1', + name: 'github', + appSlug: 'github', + accountLabel: null, + description: 'GitHub OAuth integration', + scopes: [], + requiredHosts: ['api.github.com', 'github.com'], accessTokenSecretName: 'github-access-token', refreshTokenSecretName: null, - requiredHosts: ['api.github.com', 'github.com'], - }), - description: 'GitHub OAuth integration', - appId: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ttlMs: null, + connectedAt: null, + tokenRefreshedAt: null, + createdAt: now, + updatedAt: now, + }, } vi.clearAllMocks() - mockModule.listValues.mockResolvedValue([githubIntegrationValue]) + mockModule.listValues.mockResolvedValue([]) + mockModule.listJoinedIntegrations.mockResolvedValue([githubJoinedIntegration]) + mockModule.getJoinedIntegration.mockResolvedValue(githubJoinedIntegration) mockModule.listSavedPackagesByUserId.mockResolvedValue([]) mockModule.searchCommunityListings.mockResolvedValue([ { @@ -907,7 +942,9 @@ test('integration entity detail enriches related packages without bloating ranke expect(suggestions.map((item) => item.kodyId)).toEqual(['github']) vi.clearAllMocks() - mockModule.listValues.mockResolvedValue([githubIntegrationValue]) + mockModule.listValues.mockResolvedValue([]) + mockModule.listJoinedIntegrations.mockResolvedValue([githubJoinedIntegration]) + mockModule.getJoinedIntegration.mockResolvedValue(githubJoinedIntegration) mockModule.listSavedPackagesByUserId.mockResolvedValue([ { id: 'pkg-user-github', diff --git a/packages/worker/src/mcp/tools/search-loaders.ts b/packages/worker/src/mcp/tools/search-loaders.ts index 525284dcfa..a8df808e00 100644 --- a/packages/worker/src/mcp/tools/search-loaders.ts +++ b/packages/worker/src/mcp/tools/search-loaders.ts @@ -5,6 +5,8 @@ import { listUserSecretsForSearch } from '#mcp/secrets/service.ts' import { type SecretSearchRow } from '#mcp/secrets/types.ts' import { listValues } from '#mcp/values/service.ts' import { type ValueMetadata } from '#mcp/values/types.ts' +import { listJoinedIntegrations } from '#worker/integrations/service.ts' +import { type JoinedIntegration } from '#worker/integrations/types.ts' import { listSavedPackagesByUserId } from '#worker/package-registry/repo.ts' import { getRemoteConnectorStatus, @@ -57,20 +59,28 @@ export async function loadOptionalSearchRows(input: { loadPackages: () => Promise loadUserSecrets: () => Promise> loadUserValues: () => Promise> + loadUserIntegrations: () => Promise> }): Promise { if (!input.userId) { return { packageRows: [], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], warnings: [], } } - const [loadedPackageRows, userSecretRows, userValueRows] = await Promise.all([ + const [ + loadedPackageRows, + userSecretRows, + userValueRows, + userIntegrationRows, + ] = await Promise.all([ input.loadPackages(), input.loadUserSecrets(), input.loadUserValues(), + input.loadUserIntegrations(), ]) const packageRows = Array.isArray(loadedPackageRows) ? loadedPackageRows @@ -80,6 +90,7 @@ export async function loadOptionalSearchRows(input: { packageRows, userSecretRows, userValueRows, + userIntegrationRows, warnings: [], } } @@ -128,6 +139,11 @@ export async function loadSearchRowsAndRegistry(input: { appId: input.callerContext.storageContext?.appId ?? null, }, }), + loadUserIntegrations: () => + listJoinedIntegrations({ + env: input.env, + userId: input.userId!, + }), }), ]) return { diff --git a/packages/worker/src/mcp/tools/search-types.ts b/packages/worker/src/mcp/tools/search-types.ts index 70eb6c971b..ac1a2eb1b7 100644 --- a/packages/worker/src/mcp/tools/search-types.ts +++ b/packages/worker/src/mcp/tools/search-types.ts @@ -3,6 +3,7 @@ import { type getCapabilityRegistryForContext } from '#mcp/capabilities/registry import { type SecretSearchRow } from '#mcp/secrets/types.ts' import { type MemoryToolSummary } from '#mcp/tools/memory-tool-context.ts' import { type ValueMetadata } from '#mcp/values/types.ts' +import { type JoinedIntegration } from '#worker/integrations/types.ts' import { type PackageSearchProjection } from '#worker/package-registry/manifest.ts' import { type PackageReadmeSnippet } from '#worker/package-registry/package-readme.ts' import { type listSavedPackagesByUserId } from '#worker/package-registry/repo.ts' @@ -24,6 +25,7 @@ export type OptionalSearchRowsResult = { packageRows: Array userSecretRows: Array userValueRows: Array + userIntegrationRows: Array warnings: Array } diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index d1778d4d25..1bd5722f86 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -8,12 +8,12 @@ import { } from '#mcp/capabilities/capability-search.ts' import { filterCapabilityRegistryForCaller } from '#mcp/capabilities/access-control.ts' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' -import { buildIntegrationValueName } from '#mcp/capabilities/integrations/integration-shared.ts' import { synthesizeMcpServerToolDomain } from '#mcp/capabilities/mcp-server/index.ts' import { synthesizeOpenApiProviderDomain } from '#mcp/capabilities/openapi-provider/index.ts' import { synthesizeRemoteToolDomain } from '#mcp/capabilities/remote-connector/index.ts' import { repoGetCheckStatusCapability } from '#mcp/capabilities/repo/index.ts' import { createMcpCallerContext } from '#mcp/context.ts' +import { type JoinedIntegration } from '#worker/integrations/types.ts' import { type OpenApiBinding } from '#worker/openapi/binding-shared.ts' import type * as PackageRegistrySource from '#worker/package-registry/source.ts' import { parseAuthoredPackageJson } from '#worker/package-registry/manifest.ts' @@ -27,6 +27,76 @@ import { type PackageSearchRow, } from './search.ts' +function createJoinedIntegration(input: { + userId?: string + name: string + description?: string + appSlug?: string + provider?: string + clientId?: string + tokenUrl?: string + apiBaseUrl?: string | null + authorizeUrl?: string | null + flow?: JoinedIntegration['app']['flow'] + scopes?: Array + requiredHosts?: Array + accessTokenSecretName?: string + refreshTokenSecretName?: string | null + clientSecretSecretName?: string | null +}): JoinedIntegration { + const userId = input.userId ?? 'user-1' + const appSlug = input.appSlug ?? input.name + const now = '2026-04-20T00:00:00.000Z' + return { + app: { + userId, + slug: appSlug, + provider: input.provider ?? appSlug.split('-')[0] ?? appSlug, + label: null, + clientId: input.clientId ?? `${input.name}-client-id`, + clientSecretSecretName: + input.clientSecretSecretName === undefined + ? `${input.name}-client-secret` + : input.clientSecretSecretName, + tokenUrl: input.tokenUrl ?? 'https://oauth2.googleapis.com/token', + authorizeUrl: + input.authorizeUrl === undefined + ? 'https://accounts.google.com/o/oauth2/v2/auth' + : input.authorizeUrl, + apiBaseUrl: + input.apiBaseUrl === undefined + ? 'https://www.googleapis.com' + : input.apiBaseUrl, + flow: input.flow ?? 'confidential', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: now, + updatedAt: now, + }, + connection: { + userId, + name: input.name, + appSlug, + accountLabel: null, + description: input.description ?? `${input.name} integration`, + scopes: input.scopes ?? [], + requiredHosts: input.requiredHosts ?? [], + accessTokenSecretName: + input.accessTokenSecretName ?? `${input.name}-access-token`, + refreshTokenSecretName: + input.refreshTokenSecretName === undefined + ? `${input.name}-refresh-token` + : input.refreshTokenSecretName, + connectedAt: null, + tokenRefreshedAt: null, + createdAt: now, + updatedAt: now, + }, + } +} + function buildRoleGatedSearchRegistry() { const publicCapability = defineDomainCapability('meta', { name: 'public_docs_search', @@ -71,9 +141,10 @@ const emptyOptionalSearchRows = { packageRows: [], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], } satisfies Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > function createDeterministicAiBinding(): Ai { @@ -276,25 +347,20 @@ test('searchUnified ranks mixed search rows through one shared pipeline', async updatedAt: '2026-04-20T00:00:00.000Z', ttlMs: null, }, - { - name: buildIntegrationValueName('github'), - scope: 'user', - value: JSON.stringify({ - tokenUrl: 'https://delta.example/token', - apiBaseUrl: 'https://epsilon.example/api', - flow: 'confidential', - clientIdValueName: 'github-client-id', - clientSecretSecretName: 'github-client-secret', - accessTokenSecretName: 'github-access-token', - refreshTokenSecretName: 'github-refresh-token', - requiredHosts: ['epsilon.example'], - }), + ], + userIntegrationRows: [ + createJoinedIntegration({ + name: 'github', description: 'alpha beta gamma integration', - appId: null, - createdAt: '2026-04-20T00:00:00.000Z', - updatedAt: '2026-04-20T00:00:00.000Z', - ttlMs: null, - }, + tokenUrl: 'https://delta.example/token', + apiBaseUrl: 'https://epsilon.example/api', + authorizeUrl: null, + clientId: 'github-client-id', + clientSecretSecretName: 'github-client-secret', + accessTokenSecretName: 'github-access-token', + refreshTokenSecretName: 'github-refresh-token', + requiredHosts: ['epsilon.example'], + }), ], warnings: [], } satisfies OptionalSearchRowsResult @@ -328,7 +394,7 @@ test('searchUnified ranks mixed search rows through one shared pipeline', async type: 'integration', integrationName: 'github', tokenUrl: 'https://delta.example/token', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id', clientSecretSecretName: 'github-client-secret', accessTokenSecretName: 'github-access-token', refreshTokenSecretName: 'github-refresh-token', @@ -341,6 +407,184 @@ test('searchUnified ranks mixed search rows through one shared pipeline', async ) }) +test('searchUnified matches integrations by provider name, scope, and required host', async () => { + const registry = buildCapabilityRegistry([]) + const googleAppSlug = 'google' + const optionalRows = { + ...emptyOptionalSearchRows, + userIntegrationRows: [ + createJoinedIntegration({ + name: 'google-calendar', + appSlug: googleAppSlug, + provider: 'google', + description: 'Calendar connection', + clientId: 'shared-google-client-id', + scopes: ['https://www.googleapis.com/auth/calendar.readonly'], + requiredHosts: ['www.googleapis.com'], + }), + createJoinedIntegration({ + name: 'spotify', + description: 'Spotify music', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com', + authorizeUrl: 'https://accounts.spotify.com/authorize', + clientId: 'spotify-client-id', + scopes: ['user-read-playback-state'], + requiredHosts: ['api.spotify.com'], + }), + ], + } satisfies OptionalSearchRowsResult + + const byProvider = await searchUnified({ + env: {} as Env, + query: 'google-calendar', + limit: 5, + userId: 'user-1', + registry, + optionalRows, + }) + expect(byProvider.matches[0]).toMatchObject({ + type: 'integration', + integrationName: 'google-calendar', + clientId: 'shared-google-client-id', + }) + + const byScope = await searchUnified({ + env: {} as Env, + query: 'calendar.readonly', + limit: 5, + userId: 'user-1', + registry, + optionalRows, + }) + expect(byScope.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'integration', + integrationName: 'google-calendar', + }), + ]), + ) + + const byHost = await searchUnified({ + env: {} as Env, + query: 'api.spotify.com', + limit: 5, + userId: 'user-1', + registry, + optionalRows, + }) + expect(byHost.matches[0]).toMatchObject({ + type: 'integration', + integrationName: 'spotify', + requiredHosts: ['api.spotify.com'], + }) +}) + +test('searchUnified returns four connections on one shared OAuth app as distinct entities', async () => { + const registry = buildCapabilityRegistry([]) + const sharedApp = { + appSlug: 'google', + provider: 'google', + clientId: 'shared-google-client-id', + clientSecretSecretName: 'google-client-secret', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + } as const + const connections = [ + 'google', + 'google-calendar', + 'google-mail', + 'google-drive', + ] as const + const optionalRows = { + ...emptyOptionalSearchRows, + userIntegrationRows: connections.map((name) => + createJoinedIntegration({ + ...sharedApp, + name, + description: `${name} connection`, + scopes: [`scope-for-${name}`], + requiredHosts: ['www.googleapis.com'], + accessTokenSecretName: `${name}-access-token`, + refreshTokenSecretName: `${name}-refresh-token`, + }), + ), + } satisfies OptionalSearchRowsResult + + const result = await searchUnified({ + env: {} as Env, + query: 'google www.googleapis.com', + limit: 10, + userId: 'user-1', + registry, + optionalRows, + }) + const integrationNames = result.matches + .filter((match) => match.type === 'integration') + .map((match) => match.integrationName) + .sort() + expect(integrationNames).toEqual([...connections].sort()) + expect( + result.matches + .filter((match) => match.type === 'integration') + .every( + (match) => + match.type === 'integration' && + match.clientId === 'shared-google-client-id', + ), + ).toBe(true) +}) + +test('searchUnified integration candidates stay scoped to the caller userId rows', async () => { + const registry = buildCapabilityRegistry([]) + const optionalRows = { + ...emptyOptionalSearchRows, + userIntegrationRows: [ + createJoinedIntegration({ + userId: 'user-1', + name: 'github', + description: 'user-1 github', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + requiredHosts: ['api.github.com'], + }), + ], + } satisfies OptionalSearchRowsResult + + const user1 = await searchUnified({ + env: {} as Env, + query: 'github', + limit: 5, + userId: 'user-1', + registry, + optionalRows, + }) + expect(user1.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'integration', + integrationName: 'github', + }), + ]), + ) + + // Loader rows are already user-scoped; an empty load for another user must + // not surface the first user's integrations. + const user2 = await searchUnified({ + env: {} as Env, + query: 'github', + limit: 5, + userId: 'user-2', + registry, + optionalRows: emptyOptionalSearchRows, + }) + expect(user2.matches.filter((match) => match.type === 'integration')).toEqual( + [], + ) +}) + test('searchUnified hides admin capabilities from non-admins in offline search', async () => { const registry = buildRoleGatedSearchRegistry() const regularRegistry = filterCapabilityRegistryForCaller( @@ -537,6 +781,7 @@ test('optional search rows load packages and values without partial fallbacks', packageRows: [], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], } await expect( @@ -547,6 +792,7 @@ test('optional search rows load packages and values without partial fallbacks', }, loadUserSecrets: async () => [], loadUserValues: async () => [], + loadUserIntegrations: async () => [], }), ).rejects.toThrow('packages unavailable') @@ -590,11 +836,13 @@ test('optional search rows load packages and values without partial fallbacks', ], loadUserSecrets: async () => [], loadUserValues: async () => [], + loadUserIntegrations: async () => [], }) expect(savedPackage.packageRows).toHaveLength(1) expect(savedPackage.packageRows[0]?.record.kodyId).toBe('roku-remote') expect(savedPackage.userSecretRows).toEqual([]) expect(savedPackage.userValueRows).toEqual([]) + expect(savedPackage.userIntegrationRows).toEqual([]) expect(savedPackage.warnings).toEqual([]) await expect( @@ -605,6 +853,7 @@ test('optional search rows load packages and values without partial fallbacks', loadUserValues: async () => { throw new Error('values unavailable') }, + loadUserIntegrations: async () => [], }), ).rejects.toThrow('values unavailable') @@ -617,6 +866,9 @@ test('optional search rows load packages and values without partial fallbacks', loadUserValues: async () => { throw new Error('should not run') }, + loadUserIntegrations: async () => { + throw new Error('should not run') + }, }) expect(anonymous).toEqual({ ...emptyRows, @@ -683,6 +935,7 @@ test('searchUnified annotates high-confidence package action matches', async () packageRows: [packageRow], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) @@ -726,6 +979,7 @@ test('searchUnified annotates high-confidence package action matches', async () ], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) const broadPackageMatch = broadQuery.matches.find( @@ -817,6 +1071,7 @@ export declare function traceProcessorFailure(messageId: string): Promise packageRows: rows.rows, userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) @@ -880,6 +1135,7 @@ export declare function traceProcessorFailure(messageId: string): Promise packageRows: failedHydration.rows, userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) expect(degraded.matches).toEqual( @@ -935,6 +1191,7 @@ test('searchUnified degrades to lexical package ranking when the vector query th ], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) @@ -1282,7 +1539,12 @@ test('searchUnified shares query embedding, fail-closes package isolation, and k limit: 5, userId: 'user-1', registry, - optionalRows: { packageRows, userSecretRows: [], userValueRows: [] }, + optionalRows: { + packageRows, + userSecretRows: [], + userValueRows: [], + userIntegrationRows: [], + }, }) expect(aiRunCount).toBe(1) expect(maxInFlightQueries).toBeGreaterThanOrEqual(2) @@ -1308,6 +1570,7 @@ test('searchUnified shares query embedding, fail-closes package isolation, and k packageRows: [packageRows[0]!], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) expect(packageQueryCount).toBe(0) @@ -1325,6 +1588,7 @@ test('searchUnified shares query embedding, fail-closes package isolation, and k packageRows: [packageRows[0]!], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) ).matches.filter((match) => match.type === 'package'), @@ -1349,6 +1613,7 @@ test('searchUnified shares query embedding, fail-closes package isolation, and k ], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) expect(packageQueryCount).toBe(0) @@ -1413,6 +1678,7 @@ test('searchUnified inspect affinity: live-status, package-oriented, and generic packageRows: [opsPackage, notesPackage], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) expect(live.intent.task.name).toBe('inspect') @@ -1443,6 +1709,7 @@ test('searchUnified inspect affinity: live-status, package-oriented, and generic packageRows: [notesPackage], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) expect(packageOriented.matches[0]).toMatchObject({ @@ -1471,6 +1738,7 @@ test('searchUnified inspect affinity: live-status, package-oriented, and generic ttlMs: null, }, ], + userIntegrationRows: [], }, }) expect(genericValue.intent.task.name).toBe('inspect') @@ -1654,9 +1922,10 @@ test('online search ranks remote, MCP, and OpenAPI Sonos operations above real c ttlMs: null, }, ], + userIntegrationRows: [], } satisfies Pick< OptionalSearchRowsResult, - 'packageRows' | 'userSecretRows' | 'userValueRows' + 'packageRows' | 'userSecretRows' | 'userValueRows' | 'userIntegrationRows' > const cases = [ @@ -1755,6 +2024,7 @@ test('online search ranks remote, MCP, and OpenAPI Sonos operations above real c packageRows: mixedCandidateRows.packageRows, userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, }) const exactPackageIndex = exactPackage.matches.findIndex( @@ -1779,6 +2049,7 @@ test('online search ranks remote, MCP, and OpenAPI Sonos operations above real c packageRows: [], userSecretRows: [], userValueRows: mixedCandidateRows.userValueRows, + userIntegrationRows: [], }, }) const exactValueIndex = exactValue.matches.findIndex( @@ -1985,6 +2256,7 @@ test('searchUnified domain scoping: filter, browse, reject unknown, and overview ], userSecretRows: [], userValueRows: [], + userIntegrationRows: [], }, domain: 'email', }) From 0b8af9e9438d21e8bedd909e541ef63880d17c85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:30 +0000 Subject: [PATCH 04/15] refactor(runtime): resolve integration client ids inline createAuthenticatedFetch and the OpenAPI integration-auth path each read the client id back out of the values store through clientIdValueName. The id now arrives on the config, which removes a sandbox round-trip and the 'Client ID value not found' failure mode from the token refresh path. Host allowlist enforcement, secret placeholder construction, and the 401-refresh-retry are unchanged. Co-authored-by: Kent C. Dodds --- .../operation-request.node.test.ts | 117 +++++++++++++++--- .../openapi-provider/operation-request.ts | 69 +++-------- .../authenticated-fetch.node.test.ts | 85 +++++++++++-- .../kody-runtime-utils.node.test.ts | 62 ++++++++-- .../mcp/execute-modules/kody-runtime-utils.ts | 60 ++------- 5 files changed, 253 insertions(+), 140 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts index 60801c5ce5..da09fa294b 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts @@ -1,12 +1,27 @@ import { expect, test, vi } from 'vitest' import { McpCallerError } from '#mcp/caller-error.ts' +import { IntegrationHostNotAllowedError } from '#mcp/execute-modules/integration-host-allowlist.ts' import * as secretService from '#mcp/secrets/service.ts' import * as communityRepo from '#worker/community/repo.ts' +import * as integrationsService from '#worker/integrations/service.ts' import * as packageRepo from '#worker/package-registry/repo.ts' import * as usageModule from '#worker/usage/record-usage.ts' import { type OpenApiBinding } from '#worker/openapi/binding-shared.ts' import { executeOpenApiOperationRequest } from './operation-request.ts' +const spotifyIntegrationConfig = { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce' as const, + clientId: 'client-id-value', + clientSecretSecretName: null, + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: ['api.spotify.com', 'accounts.spotify.com'], + authorization: null, +} + const bindingBase: OpenApiBinding = { name: 'widgets', specUrl: 'https://specs.example/openapi.json', @@ -484,6 +499,9 @@ test('integration auth 401 triggers host-side refresh retry', async () => { const usageSpy = vi .spyOn(usageModule, 'recordUsage') .mockResolvedValue(undefined) + const getIntegrationSpy = vi + .spyOn(integrationsService, 'getIntegration') + .mockResolvedValue(spotifyIntegrationConfig) const resolveSpy = vi .spyOn(secretService, 'resolveSecret') .mockResolvedValue({ @@ -508,30 +526,14 @@ test('integration auth 401 triggers host-side refresh retry', async () => { allowedPackages: [], }) - const entries = new Map([ - [ - '_integration:spotify', - JSON.stringify({ - name: 'spotify', - tokenUrl: 'https://accounts.spotify.com/api/token', - apiBaseUrl: 'https://api.spotify.com/v1', - flow: 'pkce', - clientIdValueName: 'spotify-client-id', - clientSecretSecretName: null, - accessTokenSecretName: 'spotifyAccessToken', - refreshTokenSecretName: 'spotifyRefreshToken', - requiredHosts: ['api.spotify.com', 'accounts.spotify.com'], - }), - ], - ['spotify-client-id', 'client-id-value'], - ]) - try { let apiCalls = 0 let firstUnauthorizedResponse: Response | null = null const fetchStub = vi.fn(async (request: Request) => { if (request.url.includes('/api/token')) { - expect(await request.text()).toContain('grant_type=refresh_token') + const body = await request.text() + expect(body).toContain('grant_type=refresh_token') + expect(body).toContain('client_id=client-id-value') return new Response( JSON.stringify({ access_token: 'new-access', @@ -557,7 +559,7 @@ test('integration auth 401 triggers host-side refresh retry', async () => { }) const result = await executeOpenApiOperationRequest({ - env: createEnv(entries), + env: createEnv(), userId: 'user-1', baseUrl: 'https://app.example.com', storageContext: null, @@ -581,13 +583,88 @@ test('integration auth 401 triggers host-side refresh retry', async () => { expect(saveSecretSpy).toHaveBeenCalled() expect(firstUnauthorizedResponse).not.toBeNull() expect(firstUnauthorizedResponse!.bodyUsed).toBe(true) + expect(getIntegrationSpy).toHaveBeenCalledWith({ + env: expect.anything(), + userId: 'user-1', + name: 'spotify', + }) } finally { + getIntegrationSpy.mockRestore() resolveSpy.mockRestore() saveSecretSpy.mockRestore() usageSpy.mockRestore() } }) +test('integration auth enforces binding host pin and integration allowlist before attaching tokens', async () => { + const usageSpy = vi + .spyOn(usageModule, 'recordUsage') + .mockResolvedValue(undefined) + const getIntegrationSpy = vi + .spyOn(integrationsService, 'getIntegration') + .mockResolvedValue({ + ...spotifyIntegrationConfig, + // Allowlist excludes the binding host so integration allowlist fails + // after the binding pin would have passed for a matching host. + requiredHosts: ['accounts.spotify.com'], + apiBaseUrl: 'https://accounts.spotify.com', + }) + const fetchStub = vi.fn(async () => { + throw new Error('fetch must not run when host checks fail') + }) + + try { + // Binding pin: operation path cannot widen away from binding apiBaseUrl. + await expect( + executeOpenApiOperationRequest({ + env: createEnv(), + userId: 'user-1', + baseUrl: 'https://app.example.com', + storageContext: null, + binding: { + ...bindingBase, + apiBaseUrl: 'https://api.spotify.com/v1', + auth: { kind: 'integration', provider: 'spotify' }, + }, + operation: { + ...getWidget, + path: 'https://evil.example/steal', + parameters: [], + }, + args: {}, + globalFetch: fetchStub as unknown as typeof fetch, + }), + ).rejects.toThrow(/must be relative to the binding apiBaseUrl/) + + // Integration allowlist: binding host is pinned, but not in requiredHosts. + await expect( + executeOpenApiOperationRequest({ + env: createEnv(), + userId: 'user-1', + baseUrl: 'https://app.example.com', + storageContext: null, + binding: { + ...bindingBase, + apiBaseUrl: 'https://api.spotify.com/v1', + auth: { kind: 'integration', provider: 'spotify' }, + }, + operation: { + ...getWidget, + path: '/me', + parameters: [], + }, + args: {}, + globalFetch: fetchStub as unknown as typeof fetch, + }), + ).rejects.toBeInstanceOf(IntegrationHostNotAllowedError) + + expect(fetchStub).not.toHaveBeenCalled() + } finally { + getIntegrationSpy.mockRestore() + usageSpy.mockRestore() + } +}) + test('passes application/json-seq request bodies through as strings', async () => { const usageSpy = vi .spyOn(usageModule, 'recordUsage') diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts index 2adfbf6173..f3bde67c0e 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts @@ -1,9 +1,3 @@ -import { - buildIntegrationValueName, - parseIntegrationConfig, - parseIntegrationJson, - type IntegrationConfig, -} from '#mcp/capabilities/integrations/integration-shared.ts' import { McpCallerError } from '#mcp/caller-error.ts' import { assertIntegrationHostAllowed } from '#mcp/execute-modules/integration-host-allowlist.ts' import { executeGatewayFetch } from '#mcp/fetch-gateway.ts' @@ -18,7 +12,10 @@ import { updateUserSecretForPackage, } from '#mcp/secrets/service.ts' import { type StorageContext } from '#mcp/storage.ts' -import { getValue } from '#mcp/values/service.ts' +import { + getIntegration, + type IntegrationConfigWithClientId, +} from '#worker/integrations/service.ts' import { normalizeApiBaseUrl, type OpenApiBinding, @@ -65,7 +62,7 @@ export async function executeOpenApiOperationRequest(input: { }) assertPinnedToApiBaseUrl(url, input.binding.apiBaseUrl) - let integration: IntegrationConfig | null = null + let integration: IntegrationConfigWithClientId | null = null if (input.binding.auth.kind === 'integration') { integration = await loadIntegrationConfig({ env: input.env, @@ -209,35 +206,24 @@ async function loadIntegrationConfig(input: { env: Pick userId: string provider: string -}): Promise { - const value = await getValue({ +}): Promise { + const integration = await getIntegration({ env: input.env, userId: input.userId, - name: buildIntegrationValueName(input.provider), - scope: 'user', - storageContext: null, + name: input.provider, }) - if (!value) { + if (!integration) { throw new Error( `Integration "${input.provider}" was not found for OpenAPI binding auth.`, ) } - const parsed = parseIntegrationConfig( - parseIntegrationJson(value.value), - input.provider, - ) - if (!parsed) { - throw new Error( - `Integration "${input.provider}" config is invalid for OpenAPI binding auth.`, - ) - } - return parsed + return integration } function buildRequestHeaders(input: { userHeaders: Record auth: OpenApiBinding['auth'] - integration: IntegrationConfig | null + integration: IntegrationConfigWithClientId | null requestBody: OpenApiBindingOperation['requestBody'] hasBody: boolean }) { @@ -362,7 +348,7 @@ async function tryRefreshIntegrationAccessToken(input: { userId: string baseUrl: string provider: string - integration: IntegrationConfig + integration: IntegrationConfigWithClientId storageContext: StorageContext | null globalFetch?: typeof fetch }): Promise<{ ok: boolean; guidance?: string }> { @@ -375,18 +361,11 @@ async function tryRefreshIntegrationAccessToken(input: { } } - let clientId: string - try { - clientId = await readClientIdValue({ - env: input.env, - userId: input.userId, - valueName: input.integration.clientIdValueName, - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) + const clientId = input.integration.clientId.trim() + if (!clientId) { return { ok: false, - guidance: `OpenAPI request returned HTTP 401; host-side token refresh could not read client id: ${message}. Call refreshAccessToken("${input.provider}") from execute, then retry.`, + guidance: `OpenAPI request returned HTTP 401; integration "${input.provider}" has no clientId. Call refreshAccessToken("${input.provider}") from execute, then retry.`, } } @@ -555,24 +534,6 @@ async function assertPackageCanUpdateUserSecret(input: { }) } -async function readClientIdValue(input: { - env: Pick - userId: string - valueName: string -}) { - const value = await getValue({ - env: input.env, - userId: input.userId, - name: input.valueName, - scope: 'user', - storageContext: null, - }) - if (!value?.value) { - throw new Error(`Client ID value "${input.valueName}" was not found.`) - } - return value.value -} - async function readOperationResponse( response: Response, ): Promise { diff --git a/packages/worker/src/mcp/execute-modules/authenticated-fetch.node.test.ts b/packages/worker/src/mcp/execute-modules/authenticated-fetch.node.test.ts index e965a5dbc2..ea2dd74bc2 100644 --- a/packages/worker/src/mcp/execute-modules/authenticated-fetch.node.test.ts +++ b/packages/worker/src/mcp/execute-modules/authenticated-fetch.node.test.ts @@ -1,3 +1,4 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { @@ -24,7 +25,7 @@ const spotifyIntegration = { tokenUrl: 'https://accounts.spotify.test/api/token', apiBaseUrl: 'https://api.spotify.com/v1', flow: 'pkce' as const, - clientIdValueName: 'spotifyClientId', + clientId: 'spotify-client-id', clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', @@ -39,11 +40,6 @@ function createKody() { expect(name).toBe('spotify') return { integration: spotifyIntegration } }, - async value_get(args: CapabilityArgs) { - const name = args.name - expect(name).toBe('spotifyClientId') - return { value: 'spotify-client-id' } - }, async secret_set(args: CapabilityArgs) { const call = args as SecretSetCall secretSetCalls.push(call) @@ -130,9 +126,6 @@ test('createAuthenticatedFetch enforces integration host allowlists and fails cl async integration_get() { return { integration: emptyIntegration } }, - async value_get() { - return { value: 'spotify-client-id' } - }, async secret_set(args: CapabilityArgs) { const call = args as { name: string; value: string; scope: string } return { name: call.name, scope: call.scope } @@ -159,3 +152,77 @@ test('createAuthenticatedFetch enforces integration host allowlists and fails cl ).rejects.toThrow(/no allowed hosts configured/) expect(emptyAllowlistFetchCalls.length).toBe(fetchCallsBefore) }) + +test('createAuthenticatedFetch attaches bearer placeholder and refreshes on 401 with inline client id', async () => { + const { kody, secretSetCalls } = createKody() + const fetchCalls: Array = [] + let apiCalls = 0 + { + using _interceptor = (() => { + const interceptor = new FetchInterceptor() + interceptor.on('request', ({ request, controller }) => { + void (async () => { + try { + fetchCalls.push(request.clone()) + if (request.url === spotifyIntegration.tokenUrl) { + await controller.respondWith( + Response.json( + { access_token: fakeAccessToken }, + { headers: { 'content-type': 'application/json' } }, + ), + ) + return + } + apiCalls += 1 + if (apiCalls === 1) { + await controller.respondWith( + Response.json( + { error: 'expired' }, + { + status: 401, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + return + } + await controller.respondWith( + Response.json( + { ok: true }, + { headers: { 'content-type': 'application/json' } }, + ), + ) + } catch (error) { + controller.errorWith(error) + } + })() + }) + interceptor.apply() + return { + [Symbol.dispose]() { + interceptor.dispose() + }, + } + })() + const authenticatedFetch = await createAuthenticatedFetch(kody, 'spotify') + const response = await authenticatedFetch('https://api.spotify.com/v1/me') + expect(await response.json()).toEqual({ ok: true }) + } + + expect(fetchCalls).toHaveLength(3) + expect(fetchCalls[0]?.headers.get('authorization')).toBe( + spotifyAccessTokenPlaceholder, + ) + expect(fetchCalls[1]?.url).toBe(spotifyIntegration.tokenUrl) + expect(await fetchCalls[1]?.text()).toContain('client_id=spotify-client-id') + expect(fetchCalls[2]?.headers.get('authorization')).toBe( + `Bearer ${fakeAccessToken}`, + ) + expect(secretSetCalls).toEqual([ + { + name: 'spotifyAccessToken', + value: fakeAccessToken, + scope: 'user', + }, + ]) +}) diff --git a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.node.test.ts b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.node.test.ts index 70c3474005..86b49eb3bc 100644 --- a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.node.test.ts +++ b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.node.test.ts @@ -40,25 +40,32 @@ const spotifyIntegration = { tokenUrl: 'https://accounts.spotify.test/api/token', apiBaseUrl: 'https://api.spotify.test/v1', flow: 'pkce' as const, - clientIdValueName: 'spotifyClientId', + clientId: 'spotify-client-id', clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', requiredHosts: ['api.spotify.test'], } -function createKody() { +const githubConfidentialIntegration = { + name: 'github', + tokenUrl: 'https://github.test/login/oauth/access_token', + apiBaseUrl: 'https://api.github.test', + flow: 'confidential' as const, + clientId: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: 'githubRefreshToken', + requiredHosts: ['api.github.test'], +} + +function createKody(integration = spotifyIntegration) { const secretSetCalls: Array = [] const kody = { async integration_get(args: CapabilityArgs) { const name = args.name - expect(name).toBe('spotify') - return { integration: spotifyIntegration } - }, - async value_get(args: CapabilityArgs) { - const name = args.name - expect(name).toBe('spotifyClientId') - return { value: 'spotify-client-id' } + expect(name).toBe(integration.name) + return { integration } }, async secret_set(args: CapabilityArgs) { const call = args as SecretSetCall @@ -190,9 +197,12 @@ test('kody oauth helpers refresh tokens, retry on missing or expired access toke ]) expect(rotatedRefreshFetchCalls).toHaveLength(1) expect(rotatedRefreshFetchCalls[0]?.method).toBe('POST') - expect(await rotatedRefreshFetchCalls[0]?.text()).toContain( + const rotatedRefreshBody = await rotatedRefreshFetchCalls[0]?.text() + expect(rotatedRefreshBody).toContain( 'refresh_token=%7B%7Bsecret%3AspotifyRefreshToken%7Cscope%3Duser%7D%7D', ) + expect(rotatedRefreshBody).toContain('client_id=spotify-client-id') + expect(rotatedRefreshBody).not.toContain('client_secret=') const storedTokenFetchCalls: Array = [] const { kody: storedTokenKody, secretSetCalls: storedSecretSetCalls } = @@ -390,3 +400,35 @@ test('createExecuteHelperPrelude exposes sandbox helpers for token refresh, auth }).toString(), ) }) + +test('confidential-flow token refresh includes inline client id and client secret placeholder', async () => { + const fetchCalls: Array = [] + const { kody, secretSetCalls } = createKody(githubConfidentialIntegration) + { + using _server = createMswNodeServer([ + http.post(githubConfidentialIntegration.tokenUrl, async ({ request }) => { + fetchCalls.push(request.clone()) + return HttpResponse.json({ access_token: 'new-github-access' }) + }), + ]) + const accessToken = await refreshAccessToken(kody, 'github') + expect(accessToken).toBe('new-github-access') + } + expect(secretSetCalls).toEqual([ + { + name: 'githubAccessToken', + value: 'new-github-access', + scope: 'user', + }, + ]) + expect(fetchCalls).toHaveLength(1) + const body = await fetchCalls[0]?.text() + expect(body).toContain('grant_type=refresh_token') + expect(body).toContain('client_id=github-client-id') + expect(body).toContain( + 'client_secret=%7B%7Bsecret%3AgithubClientSecret%7Cscope%3Duser%7D%7D', + ) + expect(body).toContain( + 'refresh_token=%7B%7Bsecret%3AgithubRefreshToken%7Cscope%3Duser%7D%7D', + ) +}) diff --git a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts index 6a03a1d2f6..2976bdfc65 100644 --- a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts +++ b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts @@ -20,7 +20,7 @@ type IntegrationConfig = { tokenUrl: string apiBaseUrl?: string | null flow: 'pkce' | 'confidential' - clientIdValueName: string + clientId: string clientSecretSecretName?: string | null accessTokenSecretName: string refreshTokenSecretName?: string | null @@ -37,17 +37,6 @@ type IntegrationGetResult = { integration: IntegrationConfig | null } -type ValueGetResult = { - name: string - scope: string - value: string - description: string - app_id: string | null - created_at: string - updated_at: string - ttl_ms: number | null -} | null - export type ExecuteRequestInput = string | URL | Request export type BasicAuthSecretHeaderInput = { @@ -84,7 +73,6 @@ export const secretHeaders = { export const EXECUTE_HELPER_CAPABILITY_NAMES = [ 'integration_get', - 'value_get', 'secret_set', ] as const @@ -213,25 +201,6 @@ async function readIntegrationConfig( return integration } -async function readClientId( - kody: KodyNamespace, - integration: IntegrationConfig, -) { - const valueGet = kody.value_get - if (typeof valueGet !== 'function') { - throw new Error('kody.value_get is not available in this sandbox.') - } - const value = (await valueGet({ - name: integration.clientIdValueName, - })) as ValueGetResult - if (!value?.value) { - throw new Error( - `Client ID value "${integration.clientIdValueName}" was not found.`, - ) - } - return value.value -} - async function persistSecret( kody: KodyNamespace, providerName: string, @@ -261,7 +230,12 @@ async function refreshAccessTokenWithIntegration( providerName: string, integration: IntegrationConfig, ) { - const clientId = await readClientId(kody, integration) + const clientId = integration.clientId.trim() + if (!clientId) { + throw new Error( + `Integration "${providerName}" does not define a client id.`, + ) + } const refreshTokenSecretName = integration.refreshTokenSecretName?.trim() ?? '' if (!refreshTokenSecretName) { @@ -588,19 +562,6 @@ const __kodyReadIntegrationConfig = async (providerName) => { } return integration; }; -const __kodyReadClientId = async (integration) => { - const valueGet = kody.value_get; - if (typeof valueGet !== 'function') { - throw new Error('kody.value_get is not available in this sandbox.'); - } - const value = await valueGet({ name: integration.clientIdValueName }); - if (!value?.value) { - throw new Error( - \`Client ID value "\${integration.clientIdValueName}" was not found.\`, - ); - } - return value.value; -}; const __kodyPersistSecret = async ( providerName, secretName, @@ -670,7 +631,12 @@ const __kodyResolveRequestUrl = (input, integration) => { }; const __kodyRefreshAccessToken = async (providerName) => { const integration = await __kodyReadIntegrationConfig(providerName); - const clientId = await __kodyReadClientId(integration); + const clientId = integration.clientId.trim(); + if (!clientId) { + throw new Error( + \`Integration "\${providerName}" does not define a client id.\`, + ); + } const refreshTokenSecretName = integration.refreshTokenSecretName?.trim() ?? ''; if (!refreshTokenSecretName) { throw new Error( From 281f728155c9892e6776cbd73b47100dcbf94487 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:47 +0000 Subject: [PATCH 05/15] feat(account): group connections by oauth app and stop reading values in connect The connect flow reached into the values store from the browser: it rebuilt the _integration: value name client-side, read the config with value_get, read the client id with a second value_get, and wrote it back with value_set. It now goes through GET /account/integrations.json?name=. The integrations page groups connections under the OAuth app they share, so four Google accounts read as four accounts on one app rather than four unrelated integrations. Tokens continue to land only in the secret store. Co-authored-by: Kent C. Dodds --- .../client/routes/account-integrations.tsx | 188 +++++++---- .../client/routes/connect-oauth.node.test.ts | 70 +++- .../worker/client/routes/connect-oauth.tsx | 209 +++++------- .../routes/integration-filter.node.test.ts | 73 +++- .../client/routes/integration-filter.ts | 55 ++- .../src/app/account-integrations-data.ts | 75 +++-- .../account-integrations.node.test.ts | 315 +++++++++++++++--- .../src/app/handlers/account-integrations.ts | 13 +- .../app/handlers/account-secrets.node.test.ts | 144 ++++++-- .../src/app/handlers/account-secrets.ts | 82 ++--- packages/worker/src/app/loader-data.ts | 14 +- packages/worker/tsconfig-client.json | 6 +- 12 files changed, 891 insertions(+), 353 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index ce7343e35c..803134d486 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -36,7 +36,12 @@ import { buildIntegrationSetupPrompt, integrationProviderSuggestions, } from '#client/routes/integration-provider-catalog.ts' -import { filterIntegrations } from '#client/routes/integration-filter.ts' +import { + filterIntegrations, + groupIntegrationsByApp, + integrationDisplayName, + oauthAppDisplayName, +} from '#client/routes/integration-filter.ts' import { colors, radius, spacing, typography } from '#client/styles/tokens.ts' import { cardCss, @@ -252,6 +257,11 @@ export function AccountIntegrationsRoute(handle: Handle) { integrations.find( (integration) => integration.name === selection.selectedId, ) ?? null + const selectedAppConnectionCount = selectedIntegration + ? integrations.filter( + (entry) => entry.appSlug === selectedIntegration.appSlug, + ).length + : 0 const showIntegrationNotFound = selection.selectedId != null && !selectedIntegration && status === 'ready' const connectHref = selectedIntegration @@ -285,11 +295,11 @@ export function AccountIntegrationsRoute(handle: Handle) { sidebar={ { replaceLocation(buildHrefWithUpdatedSearch(value)) @@ -305,56 +315,114 @@ export function AccountIntegrationsRoute(handle: Handle) { No integrations match the current filters.

) : ( - - {filteredIntegrations.map((integration) => ( -
  • - { - navigate( - integrationsRoute.buildDetailHref( - integration.name, - getCurrentSearch(), - ), - ) - }} - > - - {integration.name} - - - {connectionStatusLabel(integration)} ·{' '} - {integration.flow} - - + {groupIntegrationsByApp(filteredIntegrations).map( + (group) => { + const appTitle = oauthAppDisplayName(group) + const showAppHeader = group.connections.length > 1 + return ( +
    - Stored as {integration.valueName} - - -
  • - ))} -
    + {showAppHeader ? ( +
    + + {appTitle} + + + {group.connections.length} connections · + shared OAuth app + +
    + ) : null} + + {group.connections.map((integration) => ( +
  • + { + navigate( + integrationsRoute.buildDetailHref( + integration.name, + getCurrentSearch(), + ), + ) + }} + > + + {integrationDisplayName(integration)} + + + {connectionStatusLabel(integration)} ·{' '} + {integration.flow} + + {showAppHeader ? ( + + {integration.name} + + ) : ( + + {appTitle} + + )} + +
  • + ))} +
    + + ) + }, + )} + )}
    } @@ -370,9 +438,15 @@ export function AccountIntegrationsRoute(handle: Handle) { })} >
    -

    {selectedIntegration.name}

    +

    + {integrationDisplayName(selectedIntegration)} +

    - Stored as {selectedIntegration.valueName} + Connection {selectedIntegration.name} on + OAuth app {selectedIntegration.appSlug} + {selectedAppConnectionCount > 1 + ? ` · ${selectedAppConnectionCount} connections share this app` + : ''}

    -

    Stored names

    +

    OAuth app & secrets

    {renderIntegrationDetail( - 'Client ID value', - selectedIntegration.clientIdValueName, + 'Client ID', + selectedIntegration.clientId, )} {renderIntegrationDetail( 'Client secret', diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index bdbf2b3e12..357f019b08 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -1,6 +1,5 @@ import { expect, test } from 'vitest' import { - buildIntegrationValueName, formatOAuthExchangeFailure, isOAuthExchangeSessionExpired, mergeConnectOauthConfig, @@ -17,7 +16,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com/', flow: 'confidential', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', @@ -38,19 +37,13 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, apiBaseUrl: 'https://api.github.com/', flow: 'confidential', usePkce: null, - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', requiredHosts: ['api.github.com', 'github.com'], authorization: null, }) - // One canonical value key regardless of how the caller cases the provider. - expect(buildIntegrationValueName('GitHub')).toBe('_integration:github') - expect(buildIntegrationValueName('github')).toBe('_integration:github') - expect(buildIntegrationValueName('Spotify Family')).toBe( - '_integration:spotify-family', - ) const githubConfig = mergeConnectOauthConfig({ queryConfig: { @@ -75,7 +68,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'confidential', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', @@ -98,7 +91,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, scopeSeparator: ' ', extraAuthorizeParams: { prompt: 'consent' }, dashboardUrl: 'https://github.com/settings/developers', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', @@ -128,7 +121,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenUrl: 'https://oauth2.googleapis.com/token', apiBaseUrl: 'https://www.googleapis.com/youtube/v3', flow: 'confidential', - clientIdValueName: 'google-youtube-brand-client-id', + clientId: 'google-youtube-brand-client-id-value', clientSecretSecretName: 'googleYoutubeBrandClientSecret', accessTokenSecretName: 'googleYoutubeBrandAccessToken', refreshTokenSecretName: 'googleYoutubeBrandRefreshToken', @@ -188,7 +181,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, tokenUrl: 'https://oauth2.googleapis.com/token', apiBaseUrl: 'https://www.googleapis.com/youtube/v3', flow: 'confidential', - clientIdValueName: 'google-youtube-brand-client-id', + clientId: 'google-youtube-brand-client-id-value', clientSecretSecretName: 'googleYoutubeBrandClientSecret', accessTokenSecretName: 'googleYoutubeBrandAccessToken', refreshTokenSecretName: 'googleYoutubeBrandRefreshToken', @@ -242,7 +235,7 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs, flow: 'pkce', usePkce: true, tokenExchangeStyle: 'form', - clientIdValueName: 'spotify-client-id', + clientId: '', clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', @@ -303,7 +296,7 @@ test('connect OAuth derives Notion basic-json exchange and surfaces provider fai tokenUrl: 'https://api.notion.com/v1/oauth/token', apiBaseUrl: 'https://api.notion.com/v1', flow: 'confidential', - clientIdValueName: 'notion-client-id', + clientId: 'notion-client-id-value', clientSecretSecretName: 'notionClientSecret', accessTokenSecretName: 'notionAccessToken', refreshTokenSecretName: 'notionRefreshToken', @@ -442,7 +435,7 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho apiBaseUrl: 'https://api.canva.com/rest/v1', flow: 'confidential', usePkce: true, - clientIdValueName: 'canva-client-id', + clientId: 'canva-client-id-value', clientSecretSecretName: 'canvaClientSecret', accessTokenSecretName: 'canvaAccessToken', refreshTokenSecretName: 'canvaRefreshToken', @@ -479,10 +472,40 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho flow: 'confidential', usePkce: true, tokenExchangeStyle: 'basic-form', + clientId: 'canva-client-id-value', + }) +}) + +test('connect OAuth keeps slack comma scope separators and extra authorize params', () => { + const slackConfig = mergeConnectOauthConfig({ + queryConfig: { + provider: 'slack', + providerKey: 'slack', + authorizeHost: 'slack.com', + authorizeUrl: 'https://slack.com/oauth/v2/authorize', + tokenUrl: 'https://slack.com/api/oauth.v2.access', + apiBaseUrl: 'https://slack.com/api', + scopes: ['channels:read', 'chat:write'], + flow: 'confidential', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: ',', + extraAuthorizeParams: { user_scope: 'identify' }, + providerSetupInstructions: null, + dashboardUrl: null, + allowedHosts: ['slack.com'], + }, + storedIntegration: null, + }) + expect(slackConfig).toMatchObject({ + scopeSeparator: ',', + extraAuthorizeParams: { user_scope: 'identify' }, + usePkce: false, + clientSecretSecretName: 'slackClientSecret', }) }) -test('session config parsing is strict: usePkce is required and stale shapes are rejected', () => { +test('session config parsing is strict: usePkce and clientId are required and stale shapes are rejected', () => { const sessionConfig = { provider: 'spotify', providerKey: 'spotify', @@ -499,7 +522,7 @@ test('session config parsing is strict: usePkce is required and stale shapes are extraAuthorizeParams: {}, providerSetupInstructions: null, dashboardUrl: null, - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', clientSecretSecretName: null, accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', @@ -526,6 +549,17 @@ test('session config parsing is strict: usePkce is required and stale shapes are parseSessionConnectOauthConfig(JSON.stringify(withoutUsePkce)), ).toBeNull() + // Legacy clientIdValueName snapshots are also rejected. + expect( + parseSessionConnectOauthConfig( + JSON.stringify({ + ...sessionConfig, + clientId: undefined, + clientIdValueName: 'spotify-client-id', + }), + ), + ).toBeNull() + expect(parseSessionConnectOauthConfig('not json')).toBeNull() expect( parseSessionConnectOauthConfig(JSON.stringify({ provider: 'x' })), diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 40b8c1df73..34fc922c9c 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -7,6 +7,7 @@ import { safeParseHost, } from '@kody-internal/shared/url-hosts.ts' import { + type AccountIntegrationDetailLoaderData, type AccountIntegrationListItem, type AccountSecretsLoaderData, } from '#app/loader-data.ts' @@ -84,7 +85,7 @@ type ConnectOauthConfig = { extraAuthorizeParams: Record providerSetupInstructions: string | null dashboardUrl: string | null - clientIdValueName: string + clientId: string clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string @@ -95,8 +96,7 @@ type StoredIntegrationAuthorization = NonNullable< NonNullable > -// Persisted integration values share the core account-integration payload shape -// but omit account-only metadata and normalize nullable fields after parsing. +// Server-returned integration config used to prefill reconnects. type StoredIntegrationConfig = Omit< AccountIntegrationListItem, | 'apiBaseUrl' @@ -106,26 +106,25 @@ type StoredIntegrationConfig = Omit< | 'refreshTokenSecretName' | 'requiredHosts' | 'updatedAt' - | 'valueName' + | 'appSlug' + | 'provider' + | 'appLabel' + | 'accountLabel' > & { apiBaseUrl: string | null clientSecretSecretName: string | null refreshTokenSecretName: string | null requiredHosts: Array - usePkce: boolean | null + usePkce?: boolean | null /** Omitted when unset so persisted JSON stays sparse (matches pre-import shape). */ tokenExchangeStyle?: TokenExchangeStyle | null - authorization: StoredIntegrationAuthorization | null + authorization?: StoredIntegrationAuthorization | null } type OAuthExchangeResult = | { ok: true; data: Record; status: number } | { ok: false; status: number; error: string } -type SaveValueResult = - | { ok: true; value: { value: string } } - | { ok: false; error: string } - type SaveSecretResult = { ok: true } | { ok: false; error: string } type ConnectOauthHostApprovalLink = { @@ -167,7 +166,6 @@ export function ConnectOauthRoute(handle: Handle) { let currentStep: 'setup' | 'connect' | 'callback' | 'success' = 'setup' let config: ConnectOauthConfig | null = null let existingIntegrationConfig: StoredIntegrationConfig | null = null - let existingIntegrationValueName: string | null = null let accessTokenSaved = false let refreshTokenSaved = false let hasConfigError = false @@ -385,7 +383,7 @@ export function ConnectOauthRoute(handle: Handle) { persistConfig(nextConfig) const url = new URL(nextConfig.authorizeUrl) url.searchParams.set('response_type', 'code') - const clientId = await readValue(nextConfig.clientIdValueName) + const clientId = nextConfig.clientId.trim() if (!clientId) { throw new Error('Missing client ID. Save it before connecting.') } @@ -426,22 +424,6 @@ export function ConnectOauthRoute(handle: Handle) { return true } - const readValue = async (name: string) => { - const response = await fetch('/account/secrets.json', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ action: 'value_get', name }), - }) - if (redirectToLoginOn401(response)) return null - const payload = await response.json().catch(() => null) - if (!response.ok || payload?.ok !== true) return null - return typeof payload.value?.value === 'string' ? payload.value.value : null - } - const listSecrets = async () => { const response = await fetch('/account/secrets.json', { method: 'GET', @@ -467,27 +449,32 @@ export function ConnectOauthRoute(handle: Handle) { const readExistingIntegrationConfig = async ( queryConfig: ConnectOauthQueryConfig, - ) => { - // Integration identity is the canonical provider key, so a single - // deterministic lookup replaces the historical raw-name probe. - const valueName = buildIntegrationValueName(queryConfig.providerKey) - const raw = await readValue(valueName) - const parsed = raw - ? parseStoredIntegrationConfig(raw, queryConfig.provider) - : null - return parsed - ? { valueName, integration: parsed } - : { valueName: null, integration: null } + ): Promise => { + const response = await fetch( + `/account/integrations.json?name=${encodeURIComponent(queryConfig.providerKey)}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }, + ) + if (redirectToLoginOn401(response)) return null + const payload = (await response + .json() + .catch(() => null)) as AccountIntegrationDetailLoaderData | null + if (!response.ok || payload?.ok !== true || !payload.integration) { + return null + } + return toStoredIntegrationConfig(payload.integration) } const initializeSetupState = async (nextConfig: ConnectOauthConfig) => { - const clientId = await readValue(nextConfig.clientIdValueName) const secrets = nextConfig.clientSecretSecretName ? await listSecrets() : null - clientIdInput = clientId ?? '' + clientIdInput = nextConfig.clientId clientSecretInput = '' - hasStoredClientId = Boolean(clientId?.trim()) + hasStoredClientId = Boolean(nextConfig.clientId.trim()) hasStoredClientSecret = Boolean( nextConfig.clientSecretSecretName && secrets?.some( @@ -499,7 +486,7 @@ export function ConnectOauthRoute(handle: Handle) { revealStoredClientSecretField = false const setupStatus = summarizeStoredSetupState({ flow: nextConfig.flow, - clientId, + clientId: nextConfig.clientId, hasStoredClientSecret, }) if (setupStatus.isReady) { @@ -520,35 +507,6 @@ export function ConnectOauthRoute(handle: Handle) { setStep('setup') } - const saveValue = async ( - name: string, - value: string, - description: string, - ): Promise => { - const response = await fetch('/account/secrets.json', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - action: 'value_set', - name, - value, - description, - }), - }) - if (redirectToLoginOn401(response)) { - return { ok: false, error: 'Session expired.' } - } - const payload = await response.json().catch(() => null) - if (!response.ok || payload?.ok !== true || !payload.value?.value) { - return { ok: false, error: payload?.error || 'Unable to save value.' } - } - return { ok: true, value: { value: String(payload.value.value) } } - } - const saveSecret = async ( name: string, value: string, @@ -588,7 +546,7 @@ export function ConnectOauthRoute(handle: Handle) { ): Promise => { const params = new URLSearchParams() params.set('grant_type', 'authorization_code') - const clientId = await readValue(nextConfig.clientIdValueName) + const clientId = nextConfig.clientId.trim() if (!clientId) { return { ok: false, status: 0, error: 'Missing client ID.' } } @@ -680,15 +638,8 @@ export function ConnectOauthRoute(handle: Handle) { revealStoredClientSecretField = false clientSecretInput = '' } - const clientIdResult = await saveValue( - config.clientIdValueName, - clientId, - `${config.provider} OAuth client ID`, - ) - if (!clientIdResult.ok) { - setStatus(clientIdResult.error, 'error') - return - } + config = { ...config, clientId } + persistConfig(config) hasStoredClientId = true setStatus('Saved OAuth client configuration.', 'info') setStep('connect') @@ -770,7 +721,7 @@ export function ConnectOauthRoute(handle: Handle) { flow: config.flow, usePkce: config.usePkce, tokenExchangeStyle: config.tokenExchangeStyle, - clientIdValueName: config.clientIdValueName, + clientId: config.clientId, clientSecretSecretName: config.clientSecretSecretName, allowedHosts: config.allowedHosts, accessTokenSecretName: config.accessTokenSecretName, @@ -868,12 +819,8 @@ export function ConnectOauthRoute(handle: Handle) {

    Existing integration config

    - Loaded from{' '} - - {existingIntegrationValueName ?? - buildIntegrationValueName(config?.provider ?? '')} - - . + Loaded your saved connection{' '} + {existingIntegrationConfig.name}.

    @@ -897,9 +844,9 @@ export function ConnectOauthRoute(handle: Handle) {
    ) : null}
    - Client ID value + Client ID - {existingIntegrationConfig.clientIdValueName} + {existingIntegrationConfig.clientId}
    @@ -970,9 +917,8 @@ export function ConnectOauthRoute(handle: Handle) { (queryConfig ? mergeConnectOauthConfig({ queryConfig, - storedIntegration: ( - await readExistingIntegrationConfig(queryConfig) - ).integration, + storedIntegration: + await readExistingIntegrationConfig(queryConfig), }) : null) if (!nextConfig) { @@ -992,11 +938,10 @@ export function ConnectOauthRoute(handle: Handle) { return } const existingIntegration = await readExistingIntegrationConfig(queryConfig) - existingIntegrationConfig = existingIntegration.integration - existingIntegrationValueName = existingIntegration.valueName + existingIntegrationConfig = existingIntegration const nextConfig = mergeConnectOauthConfig({ queryConfig, - storedIntegration: existingIntegration.integration, + storedIntegration: existingIntegration, }) if (!nextConfig) { hasConfigError = true @@ -1122,8 +1067,9 @@ export function ConnectOauthRoute(handle: Handle) { />

    - Saved as {config.clientIdValueName} - {hasStoredClientId ? '.' : ' after you continue.'} + {hasStoredClientId + ? 'Stored on the OAuth app for this connection.' + : 'Saved on the OAuth app when you finish connecting.'}

    {config.flow === 'confidential' ? ( hasStoredClientSecret && !revealStoredClientSecretField ? ( @@ -1196,9 +1142,9 @@ export function ConnectOauthRoute(handle: Handle) {

    Start the OAuth flow. You will be redirected to the provider.

    - {existingIntegrationConfig ? ( + {existingIntegrationConfig && hasStoredClientId ? (

    - Using stored client ID {config.clientIdValueName} + Using stored client ID {config.flow === 'confidential' && hasStoredClientSecret ? ` and stored client secret ${config.clientSecretSecretName ?? ''}.` : '.'} @@ -1373,20 +1319,50 @@ function normalizeHosts(hosts: Array) { ).sort() } -/** - * Integration identity is the canonical provider key; mirrors - * buildIntegrationValueName in integration-shared.ts. - */ -export function buildIntegrationValueName(provider: string) { - return `_integration:${normalizeProviderKey(provider)}` +export function toStoredIntegrationConfig( + integration: AccountIntegrationListItem, +): StoredIntegrationConfig { + return { + name: integration.name, + tokenUrl: integration.tokenUrl, + apiBaseUrl: integration.apiBaseUrl?.trim() || null, + flow: integration.flow, + usePkce: + typeof integration.usePkce === 'boolean' ? integration.usePkce : null, + clientId: integration.clientId, + clientSecretSecretName: integration.clientSecretSecretName?.trim() || null, + accessTokenSecretName: integration.accessTokenSecretName, + refreshTokenSecretName: integration.refreshTokenSecretName?.trim() || null, + requiredHosts: normalizeHosts(integration.requiredHosts ?? []), + ...(integration.tokenExchangeStyle + ? { tokenExchangeStyle: integration.tokenExchangeStyle } + : {}), + authorization: integration.authorization + ? { + authorizeUrl: integration.authorization.authorizeUrl, + scopes: integration.authorization.scopes, + scopeSeparator: integration.authorization.scopeSeparator ?? null, + extraAuthorizeParams: + integration.authorization.extraAuthorizeParams ?? {}, + } + : null, + } } +/** + * Parses a stored/server integration payload for reconnect helpers and tests. + * Accepts either a JSON string or an already-decoded object with inline + * `clientId` (the first-class integrations table shape). + */ export function parseStoredIntegrationConfig( - raw: string, + raw: string | Record, fallbackProvider: string | null, ): StoredIntegrationConfig | null { try { - const parsed = JSON.parse(raw) as Record + const parsed = + typeof raw === 'string' + ? (JSON.parse(raw) as Record) + : raw const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() @@ -1395,10 +1371,8 @@ export function parseStoredIntegrationConfig( typeof parsed.tokenUrl === 'string' ? parsed.tokenUrl.trim() : '' const flow = parsed.flow === 'confidential' ? 'confidential' : 'pkce' const usePkce = typeof parsed.usePkce === 'boolean' ? parsed.usePkce : null - const clientIdValueName = - typeof parsed.clientIdValueName === 'string' - ? parsed.clientIdValueName.trim() - : '' + const clientId = + typeof parsed.clientId === 'string' ? parsed.clientId.trim() : '' const accessTokenSecretName = typeof parsed.accessTokenSecretName === 'string' ? parsed.accessTokenSecretName.trim() @@ -1424,7 +1398,7 @@ export function parseStoredIntegrationConfig( const authorization = parseStoredIntegrationAuthorization( parsed.authorization, ) - if (!name || !tokenUrl || !clientIdValueName || !accessTokenSecretName) { + if (!name || !tokenUrl || !clientId || !accessTokenSecretName) { return null } return { @@ -1436,7 +1410,7 @@ export function parseStoredIntegrationConfig( : null, flow, usePkce, - clientIdValueName, + clientId, clientSecretSecretName, accessTokenSecretName, refreshTokenSecretName, @@ -1560,8 +1534,7 @@ export function mergeConnectOauthConfig(input: { extraAuthorizeParams, providerSetupInstructions: input.queryConfig.providerSetupInstructions, dashboardUrl: input.queryConfig.dashboardUrl, - clientIdValueName: - input.storedIntegration?.clientIdValueName ?? `${providerKey}-client-id`, + clientId: input.storedIntegration?.clientId ?? '', clientSecretSecretName: flow === 'confidential' ? (input.storedIntegration?.clientSecretSecretName ?? @@ -1626,7 +1599,7 @@ export function parseSessionConnectOauthConfig( (record.flow === 'pkce' || record.flow === 'confidential') && typeof record.usePkce === 'boolean' && typeof record.scopeSeparator === 'string' && - typeof record.clientIdValueName === 'string' && + typeof record.clientId === 'string' && typeof record.accessTokenSecretName === 'string' && Array.isArray(record.scopes) && Array.isArray(record.allowedHosts) && diff --git a/packages/worker/client/routes/integration-filter.node.test.ts b/packages/worker/client/routes/integration-filter.node.test.ts index 6f0ca66f1e..af76ca9187 100644 --- a/packages/worker/client/routes/integration-filter.node.test.ts +++ b/packages/worker/client/routes/integration-filter.node.test.ts @@ -1,14 +1,22 @@ import { expect, test } from 'vitest' -import { filterIntegrations } from './integration-filter.ts' +import { + filterIntegrations, + groupIntegrationsByApp, + integrationDisplayName, + oauthAppDisplayName, +} from './integration-filter.ts' -test('integration filtering searches names, endpoints, scopes, hosts, and stored names', () => { +test('integration filtering searches names, endpoints, scopes, hosts, and app metadata', () => { const integrations = [ { name: 'github', - valueName: '_integration:github', + appSlug: 'github', + provider: 'github', + appLabel: null, + accountLabel: null, tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', accessTokenSecretName: 'github-access-token', requiredHosts: ['api.github.com'], authorization: { @@ -18,10 +26,13 @@ test('integration filtering searches names, endpoints, scopes, hosts, and stored }, { name: 'spotify', - valueName: '_integration:spotify', + appSlug: 'spotify', + provider: 'spotify', + appLabel: null, + accountLabel: null, tokenUrl: 'https://accounts.spotify.com/api/token', apiBaseUrl: 'https://api.spotify.com', - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', accessTokenSecretName: 'spotify-access-token', requiredHosts: ['api.spotify.com'], authorization: { @@ -40,3 +51,53 @@ test('integration filtering searches names, endpoints, scopes, hosts, and stored expect(filterIntegrations(integrations, 'missing')).toEqual([]) expect(filterIntegrations(integrations, '')).toEqual(integrations) }) + +test('groupIntegrationsByApp groups sibling connections under one OAuth app', () => { + const integrations = [ + { + name: 'google', + appSlug: 'google', + provider: 'google', + appLabel: null, + accountLabel: 'Personal', + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleAccessToken', + }, + { + name: 'google-calendar', + appSlug: 'google', + provider: 'google', + appLabel: null, + accountLabel: 'Work calendar', + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleCalendarAccessToken', + }, + { + name: 'github', + appSlug: 'github', + provider: 'github', + appLabel: null, + accountLabel: null, + tokenUrl: 'https://github.com/login/oauth/access_token', + clientId: 'github-client', + accessTokenSecretName: 'githubAccessToken', + }, + ] + + const groups = groupIntegrationsByApp(integrations) + expect(groups).toHaveLength(2) + expect(groups[0]).toMatchObject({ + appSlug: 'google', + clientId: 'shared-google-client', + connections: [ + expect.objectContaining({ name: 'google' }), + expect.objectContaining({ name: 'google-calendar' }), + ], + }) + expect(groups[1]?.connections).toHaveLength(1) + expect(oauthAppDisplayName(groups[0]!)).toBe('google') + expect(integrationDisplayName(integrations[0]!)).toBe('Personal') + expect(integrationDisplayName(integrations[2]!)).toBe('github') +}) diff --git a/packages/worker/client/routes/integration-filter.ts b/packages/worker/client/routes/integration-filter.ts index f849864d61..9a618e2765 100644 --- a/packages/worker/client/routes/integration-filter.ts +++ b/packages/worker/client/routes/integration-filter.ts @@ -2,10 +2,13 @@ import { matchesSearchQuery } from '#client/search-filter.ts' export type SearchableIntegration = { name: string - valueName: string + appSlug: string + provider: string + appLabel?: string | null + accountLabel?: string | null tokenUrl: string apiBaseUrl?: string | null - clientIdValueName: string + clientId: string clientSecretSecretName?: string | null accessTokenSecretName: string refreshTokenSecretName?: string | null @@ -16,6 +19,16 @@ export type SearchableIntegration = { } | null } +export type IntegrationAppGroup< + integration extends SearchableIntegration = SearchableIntegration, +> = { + appSlug: string + provider: string + appLabel: string | null + clientId: string + connections: Array +} + export function filterIntegrations( integrations: ReadonlyArray, query: string, @@ -23,10 +36,13 @@ export function filterIntegrations( return integrations.filter((integration) => matchesSearchQuery(query, [ integration.name, - integration.valueName, + integration.appSlug, + integration.provider, + integration.appLabel, + integration.accountLabel, integration.tokenUrl, integration.apiBaseUrl, - integration.clientIdValueName, + integration.clientId, integration.clientSecretSecretName, integration.accessTokenSecretName, integration.refreshTokenSecretName, @@ -36,3 +52,34 @@ export function filterIntegrations( ]), ) } + +export function groupIntegrationsByApp< + integration extends SearchableIntegration, +>( + integrations: ReadonlyArray, +): Array> { + const groups = new Map>() + for (const integration of integrations) { + const existing = groups.get(integration.appSlug) + if (existing) { + existing.connections.push(integration) + continue + } + groups.set(integration.appSlug, { + appSlug: integration.appSlug, + provider: integration.provider, + appLabel: integration.appLabel ?? null, + clientId: integration.clientId, + connections: [integration], + }) + } + return Array.from(groups.values()) +} + +export function integrationDisplayName(integration: SearchableIntegration) { + return integration.accountLabel?.trim() || integration.name +} + +export function oauthAppDisplayName(group: IntegrationAppGroup) { + return group.appLabel?.trim() || group.provider || group.appSlug +} diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index 625cf9abf6..def7475a51 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -1,51 +1,56 @@ import { type AccountIntegrationsLoaderData } from '#app/loader-data.ts' import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { - type IntegrationConfig, - parseIntegrationConfig, - parseIntegrationJson, - parseIntegrationValueName, -} from '#mcp/capabilities/integrations/integration-shared.ts' -import { listValues } from '#mcp/values/service.ts' + getJoinedIntegration, + listJoinedIntegrations, + toIntegrationConfig, + type IntegrationConfigWithClientId, +} from '#worker/integrations/service.ts' type AuthenticatedUser = NonNullable< Awaited> > -type AccountIntegrationListItem = IntegrationConfig & { - valueName: string +export type AccountIntegrationRecord = IntegrationConfigWithClientId & { + appSlug: string + provider: string + appLabel: string | null + accountLabel: string | null createdAt: string updatedAt: string } +function toAccountIntegrationRecord(input: { + app: Parameters[0] + connection: Parameters[1] +}): AccountIntegrationRecord { + const config = toIntegrationConfig(input.app, input.connection) + return { + ...config, + appSlug: input.app.slug, + provider: input.app.provider, + appLabel: input.app.label, + accountLabel: input.connection.accountLabel, + createdAt: input.connection.createdAt, + updatedAt: input.connection.updatedAt, + } +} + export async function loadAccountIntegrationsData( env: Env, user: AuthenticatedUser, ): Promise { - const values = await listValues({ + const joined = await listJoinedIntegrations({ env, userId: user.mcpUser.userId, - scope: 'user', - storageContext: { sessionId: null, appId: null }, }) - const integrations = values - .map((value) => { - const integrationName = parseIntegrationValueName(value.name) - if (!integrationName) return null - const config = parseIntegrationConfig( - parseIntegrationJson(value.value), - integrationName, - ) - if (!config) return null - return { - ...config, - valueName: value.name, - createdAt: value.createdAt, - updatedAt: value.updatedAt, - } + const integrations = joined + .map((entry) => toAccountIntegrationRecord(entry)) + .sort((left, right) => { + const appCompare = left.appSlug.localeCompare(right.appSlug) + if (appCompare !== 0) return appCompare + return left.name.localeCompare(right.name) }) - .filter((entry): entry is AccountIntegrationListItem => Boolean(entry)) - .sort((left, right) => left.name.localeCompare(right.name)) return { ok: true, @@ -54,3 +59,17 @@ export async function loadAccountIntegrationsData( integrations, } } + +export async function loadAccountIntegrationByName( + env: Env, + user: AuthenticatedUser, + name: string, +): Promise { + const joined = await getJoinedIntegration({ + env, + userId: user.mcpUser.userId, + name, + }) + if (!joined) return null + return toAccountIntegrationRecord(joined) +} diff --git a/packages/worker/src/app/handlers/account-integrations.node.test.ts b/packages/worker/src/app/handlers/account-integrations.node.test.ts index da9d40ff3f..06083ae520 100644 --- a/packages/worker/src/app/handlers/account-integrations.node.test.ts +++ b/packages/worker/src/app/handlers/account-integrations.node.test.ts @@ -1,4 +1,8 @@ import { expect, test, vi } from 'vitest' +import type * as IntegrationsService from '#worker/integrations/service.ts' + +const createdAt = '1970-01-01T00:00:00.000Z' +const updatedAt = '1970-01-01T00:00:00.001Z' const mockModule = vi.hoisted(() => ({ readAuthenticatedAppUser: vi.fn(async () => ({ @@ -16,56 +20,150 @@ const mockModule = vi.hoisted(() => ({ }, })), readAuthSessionResult: async () => ({ session: null, setCookie: null }), - listValues: vi.fn(async () => [ + listJoinedIntegrations: vi.fn(async () => [ + { + app: { + userId: 'stable-user-1', + slug: 'google', + provider: 'google', + label: null, + clientId: 'shared-google-client', + clientSecretSecretName: 'googleClientSecret', + tokenUrl: 'https://oauth2.googleapis.com/token', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, + connection: { + userId: 'stable-user-1', + name: 'google', + appSlug: 'google', + accountLabel: 'Personal', + description: '', + scopes: ['openid', 'email'], + requiredHosts: ['www.googleapis.com'], + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + connectedAt: null, + tokenRefreshedAt: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, + }, + { + app: { + userId: 'stable-user-1', + slug: 'google', + provider: 'google', + label: null, + clientId: 'shared-google-client', + clientSecretSecretName: 'googleClientSecret', + tokenUrl: 'https://oauth2.googleapis.com/token', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, + connection: { + userId: 'stable-user-1', + name: 'google-calendar', + appSlug: 'google', + accountLabel: 'Work calendar', + description: '', + scopes: ['calendar.readonly'], + requiredHosts: ['www.googleapis.com'], + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + connectedAt: null, + tokenRefreshedAt: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, + }, { - name: '_integration:github', - scope: 'user', - value: JSON.stringify({ + app: { + userId: 'stable-user-1', + slug: 'github', + provider: 'github', + label: null, + clientId: 'github-client-id-value', + clientSecretSecretName: 'githubClientSecret', tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', apiBaseUrl: 'https://api.github.com', - flow: 'confidential', - clientIdValueName: 'github-client-id', - clientSecretSecretName: 'githubClientSecret', + flow: 'confidential' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, + connection: { + userId: 'stable-user-1', + name: 'github', + appSlug: 'github', + accountLabel: null, + description: '', + scopes: ['repo', 'read:user'], + requiredHosts: ['api.github.com'], accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: null, - requiredHosts: ['api.github.com'], - authorization: { - authorizeUrl: 'https://github.com/login/oauth/authorize', - scopes: ['repo', 'read:user'], - }, - }), - description: '', - appId: null, - createdAt: '1970-01-01T00:00:00.000Z', - updatedAt: '1970-01-01T00:00:00.001Z', - ttlMs: null, + connectedAt: null, + tokenRefreshedAt: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + }, }, - { - name: '_integration:broken', - scope: 'user', - value: '{', - description: '', - appId: null, + ]), + getJoinedIntegration: vi.fn(async () => ({ + app: { + userId: 'stable-user-1', + slug: 'github', + provider: 'github', + label: null, + clientId: 'github-client-id-value', + clientSecretSecretName: 'githubClientSecret', + tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, createdAt: '1970-01-01T00:00:00.000Z', updatedAt: '1970-01-01T00:00:00.001Z', - ttlMs: null, }, - { - name: 'plain-value', - scope: 'user', - value: 'ignored', + connection: { + userId: 'stable-user-1', + name: 'github', + appSlug: 'github', + accountLabel: null, description: '', - appId: null, + scopes: ['repo', 'read:user'], + requiredHosts: ['api.github.com'], + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + connectedAt: null, + tokenRefreshedAt: null, createdAt: '1970-01-01T00:00:00.000Z', updatedAt: '1970-01-01T00:00:00.001Z', - ttlMs: null, }, - ]), + })), })) -const createdAt = '1970-01-01T00:00:00.000Z' -const updatedAt = '1970-01-01T00:00:00.001Z' - vi.mock('#app/authenticated-user.ts', () => ({ readAuthenticatedAppUser: (...args: Array) => mockModule.readAuthenticatedAppUser(...args), @@ -84,9 +182,16 @@ vi.mock('#app/ssr-render.tsx', () => ({ renderAppPage: async () => new Response('ok'), })) -vi.mock('#mcp/values/service.ts', () => ({ - listValues: (...args: Array) => mockModule.listValues(...args), -})) +vi.mock('#worker/integrations/service.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + listJoinedIntegrations: (...args: Array) => + mockModule.listJoinedIntegrations(...args), + getJoinedIntegration: (...args: Array) => + mockModule.getJoinedIntegration(...args), + } +}) const { createAccountIntegrationsApiHandler } = await import('./account-integrations.ts') @@ -98,7 +203,7 @@ function createEnv() { } as Env } -test('integrations API lists valid user-scoped OAuth integrations and skips malformed values', async () => { +test('integrations API lists connections with app grouping metadata and never exposes token values', async () => { const handler = createAccountIntegrationsApiHandler(createEnv()) const response = await handler.handler({ request: new Request('https://example.com/account/integrations.json'), @@ -107,24 +212,26 @@ test('integrations API lists valid user-scoped OAuth integrations and skips malf expect(response.status).toBe(200) expect(response.headers.get('Cache-Control')).toBe('no-store') - expect(mockModule.listValues).toHaveBeenCalledWith({ + expect(mockModule.listJoinedIntegrations).toHaveBeenCalledWith({ env: expect.any(Object), userId: 'stable-user-1', - scope: 'user', - storageContext: { sessionId: null, appId: null }, }) - await expect(response.json()).resolves.toEqual({ + const payload = await response.json() + expect(payload).toEqual({ ok: true, email: 'user@example.com', username: 'test-user', integrations: [ { name: 'github', - valueName: '_integration:github', + appSlug: 'github', + provider: 'github', + appLabel: null, + accountLabel: null, tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'confidential', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: null, @@ -138,6 +245,124 @@ test('integrations API lists valid user-scoped OAuth integrations and skips malf createdAt, updatedAt, }, + { + name: 'google', + appSlug: 'google', + provider: 'google', + appLabel: null, + accountLabel: 'Personal', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, + createdAt, + updatedAt, + }, + { + name: 'google-calendar', + appSlug: 'google', + provider: 'google', + appLabel: null, + accountLabel: 'Work calendar', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['calendar.readonly'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, + createdAt, + updatedAt, + }, ], }) + // Secret *names* are fine in the payload; raw token values must never appear. + expect(JSON.stringify(payload)).not.toMatch( + /"access_token"\s*:|"refresh_token"\s*:/, + ) + const googleConnections = payload.integrations.filter( + (entry: { appSlug: string }) => entry.appSlug === 'google', + ) + expect(googleConnections).toHaveLength(2) + expect( + new Set( + googleConnections.map((entry: { clientId: string }) => entry.clientId), + ), + ).toEqual(new Set(['shared-google-client'])) +}) + +test('integrations API returns one connection by name for the connect OAuth flow', async () => { + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request( + 'https://example.com/account/integrations.json?name=GitHub', + ), + params: {}, + } as never) + + expect(response.status).toBe(200) + expect(mockModule.getJoinedIntegration).toHaveBeenCalledWith({ + env: expect.any(Object), + userId: 'stable-user-1', + name: 'GitHub', + }) + await expect(response.json()).resolves.toEqual({ + ok: true, + integration: { + name: 'github', + appSlug: 'github', + provider: 'github', + appLabel: null, + accountLabel: null, + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientId: 'github-client-id-value', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['api.github.com'], + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: ['repo', 'read:user'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + createdAt, + updatedAt, + }, + }) +}) + +test('integrations API returns null when a named connection is missing', async () => { + mockModule.getJoinedIntegration.mockResolvedValueOnce(null) + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request( + 'https://example.com/account/integrations.json?name=missing', + ), + params: {}, + } as never) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + integration: null, + }) }) diff --git a/packages/worker/src/app/handlers/account-integrations.ts b/packages/worker/src/app/handlers/account-integrations.ts index 30cf0b3649..bb12a34bb0 100644 --- a/packages/worker/src/app/handlers/account-integrations.ts +++ b/packages/worker/src/app/handlers/account-integrations.ts @@ -1,6 +1,9 @@ import { jsonResponse } from '#worker/json-response.ts' import { type Action } from 'remix/router' -import { loadAccountIntegrationsData } from '#app/account-integrations-data.ts' +import { + loadAccountIntegrationByName, + loadAccountIntegrationsData, +} from '#app/account-integrations-data.ts' import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { requireAuthenticatedPageUser } from '#app/page-auth.ts' import { renderAppPage } from '#app/ssr-render.tsx' @@ -41,6 +44,14 @@ export function createAccountIntegrationsApiHandler(env: Env) { return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) } + const name = new URL(request.url).searchParams.get('name')?.trim() + if (name) { + return jsonResponse({ + ok: true, + integration: await loadAccountIntegrationByName(env, user, name), + }) + } + return jsonResponse(await loadAccountIntegrationsData(env, user)) }, } satisfies Action diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index c8d7c0002c..cb0be4c07c 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -41,6 +41,10 @@ const mockModule = vi.hoisted(() => ({ setSecretAllowedCapabilities: vi.fn(async () => undefined), setSecretAllowedPackages: vi.fn(async () => undefined), getValue: vi.fn(async () => null), + upsertIntegration: vi.fn(async (input: { config: { name: string } }) => ({ + ...input.config, + name: String(input.config.name).toLowerCase(), + })), searchCommunityListings: vi.fn(async () => []), })) @@ -111,6 +115,11 @@ vi.mock('#mcp/values/service.ts', () => ({ saveValue: (...args: Array) => mockModule.saveValue(...args), })) +vi.mock('#worker/integrations/service.ts', () => ({ + upsertIntegration: (...args: Array) => + mockModule.upsertIntegration(...args), +})) + vi.mock('#worker/package-registry/repo.ts', () => ({ listSavedPackagesByUserId: (...args: Array) => mockModule.listSavedPackagesByUserId(...args), @@ -125,8 +134,9 @@ function createEnv() { } as Env } -test('connect oauth saves tokens, integration metadata, and host approval links', async () => { - mockModule.saveValue.mockClear() +test('connect oauth saves tokens via the secret store and persists app+connection through the integrations service', async () => { + mockModule.upsertIntegration.mockClear() + mockModule.saveSecret.mockClear() mockModule.buildSecretHostApprovalUrl.mockClear() mockModule.setSecretAllowedHosts.mockClear() const handler = createAccountSecretsApiHandler(createEnv()) @@ -145,7 +155,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' scopeSeparator: ' ', extraAuthorizeParams: { prompt: 'consent' }, flow: 'pkce', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', allowedHosts: ['api.github.com'], @@ -203,15 +213,31 @@ test('connect oauth saves tokens, integration metadata, and host approval links' }) expect(mockModule.buildSecretHostApprovalUrl).toHaveBeenCalledTimes(4) expect(mockModule.setSecretAllowedHosts).not.toHaveBeenCalled() - expect(mockModule.saveValue).toHaveBeenCalledWith( + expect(mockModule.saveSecret).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + name: 'githubAccessToken', + value: 'access-token', + scope: 'user', + }), + ) + expect(mockModule.saveSecret).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + name: 'githubRefreshToken', + value: 'refresh-token', + scope: 'user', + }), + ) + expect(mockModule.upsertIntegration).toHaveBeenCalledWith( expect.objectContaining({ - name: '_integration:github', - value: JSON.stringify({ + userId: 'stable-user-1', + config: expect.objectContaining({ name: 'github', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', flow: 'pkce', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', clientSecretSecretName: null, accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', @@ -225,8 +251,9 @@ test('connect oauth saves tokens, integration metadata, and host approval links' }), }), ) + expect(mockModule.saveValue).not.toHaveBeenCalled() - mockModule.saveValue.mockClear() + mockModule.upsertIntegration.mockClear() const spotifyResponse = await handler.handler({ request: new Request('https://example.com/account/secrets.json', { method: 'POST', @@ -241,7 +268,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' scopeSeparator: ' ', extraAuthorizeParams: { show_dialog: 'true' }, flow: 'pkce', - clientIdValueName: 'spotify-client-id', + clientId: 'spotify-client-id-value', accessTokenSecretName: 'spotifyAccessToken', refreshTokenSecretName: 'spotifyRefreshToken', allowedHosts: ['api.spotify.com'], @@ -261,12 +288,13 @@ test('connect oauth saves tokens, integration metadata, and host approval links' refreshTokenSaved: false, integrationName: 'spotify', }) - expect(mockModule.saveValue).toHaveBeenCalledWith( + expect(mockModule.upsertIntegration).toHaveBeenCalledWith( expect.objectContaining({ - name: '_integration:spotify', - value: expect.stringContaining( - '"refreshTokenSecretName":"spotifyRefreshToken"', - ), + config: expect.objectContaining({ + name: 'spotify', + clientId: 'spotify-client-id-value', + refreshTokenSecretName: 'spotifyRefreshToken', + }), }), ) @@ -313,7 +341,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' tokenUrl: 'https://auth.tesla.com/oauth2/v3/token', apiBaseUrl: 'https://fleet-api.prd.na.vn.cloud.tesla.com', flow: 'pkce', - clientIdValueName: 'tesla-client-id', + clientId: 'tesla-client-id-value', accessTokenSecretName: 'teslaAccessToken', refreshTokenSecretName: 'teslaRefreshToken', allowedHosts: [ @@ -348,7 +376,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' }) test('connect oauth rejects invalid authorization metadata', async () => { - mockModule.saveValue.mockClear() + mockModule.upsertIntegration.mockClear() const handler = createAccountSecretsApiHandler(createEnv()) await expect( @@ -364,7 +392,7 @@ test('connect oauth rejects invalid authorization metadata', async () => { apiBaseUrl: 'https://api.github.com', scopes: ['repo'], flow: 'pkce', - clientIdValueName: 'github-client-id', + clientId: 'github-client-id-value', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: 'githubRefreshToken', allowedHosts: ['api.github.com'], @@ -377,7 +405,78 @@ test('connect oauth rejects invalid authorization metadata', async () => { params: {}, } as never), ).rejects.toThrow('OAuth integration configuration is invalid.') - expect(mockModule.saveValue).not.toHaveBeenCalled() + expect(mockModule.upsertIntegration).not.toHaveBeenCalled() +}) + +test('connect oauth reuses an existing OAuth app when connecting a second account with the same client id', async () => { + mockModule.upsertIntegration.mockClear() + const handler = createAccountSecretsApiHandler(createEnv()) + + const first = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'connect_oauth', + provider: 'google', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + scopes: ['openid', 'email'], + flow: 'pkce', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + allowedHosts: ['www.googleapis.com'], + tokenPayload: { + access_token: 'google-access-1', + refresh_token: 'google-refresh-1', + }, + }), + }), + params: {}, + } as never) + expect(first.status).toBe(200) + + const second = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'connect_oauth', + provider: 'google-calendar', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + scopes: ['calendar.readonly'], + flow: 'pkce', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + allowedHosts: ['www.googleapis.com'], + tokenPayload: { + access_token: 'google-access-2', + refresh_token: 'google-refresh-2', + }, + }), + }), + params: {}, + } as never) + expect(second.status).toBe(200) + + expect(mockModule.upsertIntegration).toHaveBeenCalledTimes(2) + expect(mockModule.upsertIntegration.mock.calls[0]?.[0]).toMatchObject({ + config: { + name: 'google', + clientId: 'shared-google-client', + }, + }) + expect(mockModule.upsertIntegration.mock.calls[1]?.[0]).toMatchObject({ + config: { + name: 'google-calendar', + clientId: 'shared-google-client', + }, + }) }) test('host approval view and approve persist normalized hosts for the selected secret', async () => { @@ -1428,7 +1527,7 @@ test('connect oauth persists usePkce for confidential + PKCE providers like Canv flow: 'confidential', usePkce: true, tokenExchangeStyle: 'basic-form', - clientIdValueName: 'canva-client-id', + clientId: 'canva-client-id-value', clientSecretSecretName: 'canvaClientSecret', accessTokenSecretName: 'canvaAccessToken', refreshTokenSecretName: 'canvaRefreshToken', @@ -1477,16 +1576,15 @@ test('connect oauth persists usePkce for confidential + PKCE providers like Canv limit: 12, trustedFirst: true, }) - expect(mockModule.saveValue).toHaveBeenCalledWith( + expect(mockModule.upsertIntegration).toHaveBeenCalledWith( expect.objectContaining({ - name: '_integration:canva', - value: JSON.stringify({ + config: expect.objectContaining({ name: 'canva', tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', apiBaseUrl: 'https://api.canva.com/rest/v1', flow: 'confidential', usePkce: true, - clientIdValueName: 'canva-client-id', + clientId: 'canva-client-id-value', clientSecretSecretName: 'canvaClientSecret', accessTokenSecretName: 'canvaAccessToken', refreshTokenSecretName: 'canvaRefreshToken', diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index c80e0982f2..ddc6f7c15e 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -1,8 +1,5 @@ import { jsonResponse } from '#worker/json-response.ts' -import { - normalizeProviderKey, - safeParseHost, -} from '@kody-internal/shared/url-hosts.ts' +import { safeParseHost } from '@kody-internal/shared/url-hosts.ts' import { type Action } from 'remix/router' import { buildAccountSecretId, @@ -39,9 +36,11 @@ import { normalizeAllowedPackages } from '#mcp/secrets/allowed-packages.ts' import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' import { getValue, saveValue } from '#mcp/values/service.ts' import { - buildIntegrationValueName, - parseIntegrationConfig, + canonicalIntegrationName, + normalizeIntegrationConfigWithClientId, + integrationConfigWithClientIdSchema, } from '#mcp/capabilities/integrations/integration-shared.ts' +import { upsertIntegration } from '#worker/integrations/service.ts' import { requireAuthenticatedPageUser } from '#app/page-auth.ts' import { buildOAuthTokenExchangeFailurePayload, @@ -242,7 +241,7 @@ async function handleConnectOauthAction(input: { const authorizeUrl = readOptionalString(input.body, 'authorizeUrl') const flow = readOptionalString(input.body, 'flow') const usePkce = readOptionalBoolean(input.body, 'usePkce') - const clientIdValueName = readOptionalString(input.body, 'clientIdValueName') + const clientId = readOptionalString(input.body, 'clientId') const clientSecretSecretName = readOptionalString( input.body, 'clientSecretSecretName', @@ -285,11 +284,8 @@ async function handleConnectOauthAction(input: { if (flow && flow !== 'pkce' && flow !== 'confidential') { return jsonResponse({ ok: false, error: 'Invalid OAuth flow.' }, 400) } - if (!clientIdValueName) { - return jsonResponse( - { ok: false, error: 'Client ID value name is required.' }, - 400, - ) + if (!clientId) { + return jsonResponse({ ok: false, error: 'Client ID is required.' }, 400) } if (!accessTokenSecretName) { return jsonResponse( @@ -349,13 +345,12 @@ async function handleConnectOauthAction(input: { const integrationName = await saveIntegrationConfig({ env: input.env, userId: input.user.mcpUser.userId, - userEmail: input.user.mcpUser.email, provider, tokenUrl, apiBaseUrl, flow: flow === 'confidential' ? 'confidential' : 'pkce', usePkce, - clientIdValueName, + clientId, clientSecretSecretName, accessTokenSecretName, refreshTokenSecretName, @@ -363,7 +358,6 @@ async function handleConnectOauthAction(input: { tokenUrl, tokenExchangeStyle: readOptionalString(input.body, 'tokenExchangeStyle'), }), - tokenPayload: tokenRecord, allowedHosts, authorization: authorizeUrl ? { @@ -603,18 +597,16 @@ async function handleOAuthExchangeAction(input: { async function saveIntegrationConfig(input: { env: Env userId: string - userEmail: string provider: string tokenUrl: string apiBaseUrl: string | null flow: 'pkce' | 'confidential' usePkce: boolean | null - clientIdValueName: string + clientId: string clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null tokenExchangeStyle: TokenExchangeStyle | null - tokenPayload: Record allowedHosts: Array authorization: { authorizeUrl: string @@ -623,44 +615,36 @@ async function saveIntegrationConfig(input: { extraAuthorizeParams: Record } | null }) { - const providerKey = normalizeProviderKey(input.provider) + const providerKey = canonicalIntegrationName(input.provider) if (!providerKey) { throw new Error('Provider must contain letters or numbers.') } - const integration = parseIntegrationConfig( - { - name: input.provider, - tokenUrl: input.tokenUrl, - apiBaseUrl: input.apiBaseUrl, - flow: input.flow, - ...(input.usePkce == null ? {} : { usePkce: input.usePkce }), - clientIdValueName: input.clientIdValueName, - clientSecretSecretName: - input.flow === 'confidential' - ? (input.clientSecretSecretName ?? `${providerKey}ClientSecret`) - : null, - accessTokenSecretName: input.accessTokenSecretName, - refreshTokenSecretName: input.refreshTokenSecretName, - requiredHosts: input.allowedHosts, - ...(input.tokenExchangeStyle && input.tokenExchangeStyle !== 'form' - ? { tokenExchangeStyle: input.tokenExchangeStyle } - : {}), - ...(input.authorization ? { authorization: input.authorization } : {}), - }, - input.provider, - ) - if (!integration) { + const parsed = integrationConfigWithClientIdSchema.safeParse({ + name: input.provider, + tokenUrl: input.tokenUrl, + apiBaseUrl: input.apiBaseUrl, + flow: input.flow, + ...(input.usePkce == null ? {} : { usePkce: input.usePkce }), + clientId: input.clientId, + clientSecretSecretName: + input.flow === 'confidential' + ? (input.clientSecretSecretName ?? `${providerKey}ClientSecret`) + : null, + accessTokenSecretName: input.accessTokenSecretName, + refreshTokenSecretName: input.refreshTokenSecretName, + requiredHosts: input.allowedHosts, + ...(input.tokenExchangeStyle && input.tokenExchangeStyle !== 'form' + ? { tokenExchangeStyle: input.tokenExchangeStyle } + : {}), + ...(input.authorization ? { authorization: input.authorization } : {}), + }) + if (!parsed.success) { throw new Error('OAuth integration configuration is invalid.') } - await saveValue({ + const integration = await upsertIntegration({ env: input.env, userId: input.userId, - userEmail: input.userEmail, - name: buildIntegrationValueName(integration.name), - value: JSON.stringify(integration), - scope: 'user', - description: `OAuth integration config for ${integration.name}`, - storageContext: { sessionId: null, appId: null, packageId: null }, + config: normalizeIntegrationConfigWithClientId(parsed.data), }) return integration.name } diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index 747cf42e30..655dde1d0f 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -579,15 +579,20 @@ export type EmailVerificationLoaderData = export type AccountIntegrationListItem = { name: string - valueName: string + appSlug: string + provider: string + appLabel: string | null + accountLabel: string | null tokenUrl: string apiBaseUrl?: string | null flow: 'pkce' | 'confidential' - clientIdValueName: string + usePkce?: boolean | null + clientId: string clientSecretSecretName?: string | null accessTokenSecretName: string refreshTokenSecretName?: string | null requiredHosts?: Array + tokenExchangeStyle?: 'form' | 'basic-json' | 'basic-form' | null authorization?: { authorizeUrl: string scopes: Array @@ -605,6 +610,11 @@ export type AccountIntegrationsLoaderData = { integrations: Array } +export type AccountIntegrationDetailLoaderData = { + ok: true + integration: AccountIntegrationListItem | null +} + export type AccountMcpServerListItem = { id: string name: string diff --git a/packages/worker/tsconfig-client.json b/packages/worker/tsconfig-client.json index 0ff308dd3d..5f678dfd5f 100644 --- a/packages/worker/tsconfig-client.json +++ b/packages/worker/tsconfig-client.json @@ -11,7 +11,8 @@ "paths": { "#app/*": ["./src/app/*"], "#client/*": ["./client/*"], - "#worker/*": ["./src/*"] + "#worker/*": ["./src/*"], + "#mcp/*": ["./src/mcp/*"] } }, "include": [ @@ -37,7 +38,8 @@ "./src/app/safe-redirect.ts", "./src/feature-flags/registry.ts", "./src/feature-flags/types.ts", - "./src/og/pages.ts" + "./src/og/pages.ts", + "./src/mcp/values/value-name-guards.ts" ], "exclude": ["./client/**/*.test.ts", "./client/**/*.spec.ts"] } From f455f97cb551ecd4ce6e525a10d4042205e9228d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:47 +0000 Subject: [PATCH 06/15] fix(values): hide platform-reserved value prefixes from agents value_list only filtered isReservedValueName, an empty Set, so agents saw platform plumbing mixed into the user's own config. The account UI already hid these prefixes via its own duplicated list. Move the guard into the values layer, filter it from value_list, reject writes to those prefixes from value_set with a pointer to the right capability, and stop leaking _openapi: rows into generic value search. Internal saveValue is deliberately unguarded because platform code still writes _openapi: rows through it. Co-authored-by: Kent C. Dodds --- .../worker/client/routes/account-values.tsx | 3 +- .../worker/src/app/account-values-data.ts | 4 +- .../app/handlers/account-values.node.test.ts | 25 +++- .../values/value-capabilities.node.test.ts | 111 ++++++++++++++++++ .../src/mcp/capabilities/values/value-list.ts | 11 +- .../src/mcp/capabilities/values/value-set.ts | 4 +- .../mcp/tools/search-entity-plugins/value.ts | 6 +- .../src/mcp/values/service.node.test.ts | 31 +++++ .../mcp/values/value-name-guards.node.test.ts | 28 +++++ .../src/mcp/values/value-name-guards.ts | 25 ++++ 10 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 packages/worker/src/mcp/capabilities/values/value-capabilities.node.test.ts create mode 100644 packages/worker/src/mcp/values/value-name-guards.node.test.ts diff --git a/packages/worker/client/routes/account-values.tsx b/packages/worker/client/routes/account-values.tsx index c96568189d..4671855c4a 100644 --- a/packages/worker/client/routes/account-values.tsx +++ b/packages/worker/client/routes/account-values.tsx @@ -45,6 +45,7 @@ import { type AccountValueListItem, type AccountValuesLoaderData, } from '#app/loader-data.ts' +import { isPlatformReservedValueName } from '#mcp/values/value-name-guards.ts' type EditorState = { name: string @@ -88,7 +89,7 @@ function filterValues(values: Array, search: string) { } function isReservedAccountValueName(name: string) { - return name.startsWith('_integration:') || name.startsWith('_openapi:') + return isPlatformReservedValueName(name) } const reservedAccountValueWriteError = diff --git a/packages/worker/src/app/account-values-data.ts b/packages/worker/src/app/account-values-data.ts index e0e70209ea..08e69bcc9c 100644 --- a/packages/worker/src/app/account-values-data.ts +++ b/packages/worker/src/app/account-values-data.ts @@ -1,5 +1,6 @@ import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { getValue, listValues } from '#mcp/values/service.ts' +import { isPlatformReservedValueName } from '#mcp/values/value-name-guards.ts' import { type ValueMetadata } from '#mcp/values/types.ts' type AuthenticatedUser = NonNullable< @@ -8,7 +9,6 @@ type AuthenticatedUser = NonNullable< const accountValuesBasePath = '/account/values' const valuePreviewMaxLength = 80 -const reservedValuePrefixes = ['_integration:', '_openapi:'] as const export type AccountValueListItem = { id: string @@ -38,7 +38,7 @@ export type AccountValuesLoaderData = { } export function isReservedAccountValueName(name: string) { - return reservedValuePrefixes.some((prefix) => name.startsWith(prefix)) + return isPlatformReservedValueName(name) } export function truncateValuePreview( diff --git a/packages/worker/src/app/handlers/account-values.node.test.ts b/packages/worker/src/app/handlers/account-values.node.test.ts index f79a9cfabc..74d8f27fc7 100644 --- a/packages/worker/src/app/handlers/account-values.node.test.ts +++ b/packages/worker/src/app/handlers/account-values.node.test.ts @@ -216,7 +216,7 @@ test('values API rejects reserved names, missing deletes, invalid actions, and u mockModule.saveValue.mockClear() mockModule.deleteValue.mockClear() - const reservedSave = await handler.handler({ + const reservedIntegrationSave = await handler.handler({ request: new Request('https://example.com/account/values.json', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -228,8 +228,27 @@ test('values API rejects reserved names, missing deletes, invalid actions, and u }), }), }) - expect(reservedSave.status).toBe(400) - await expect(reservedSave.json()).resolves.toMatchObject({ + expect(reservedIntegrationSave.status).toBe(400) + await expect(reservedIntegrationSave.json()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('reserved'), + }) + expect(mockModule.saveValue).not.toHaveBeenCalled() + + const reservedOpenApiSave = await handler.handler({ + request: new Request('https://example.com/account/values.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + name: '_openapi:widgets', + value: '{"ops":[]}', + description: 'should not save', + }), + }), + }) + expect(reservedOpenApiSave.status).toBe(400) + await expect(reservedOpenApiSave.json()).resolves.toMatchObject({ ok: false, error: expect.stringContaining('reserved'), }) diff --git a/packages/worker/src/mcp/capabilities/values/value-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/values/value-capabilities.node.test.ts new file mode 100644 index 0000000000..3f00015078 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/values/value-capabilities.node.test.ts @@ -0,0 +1,111 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockModule = vi.hoisted(() => ({ + listValues: vi.fn(), + saveValue: vi.fn(), +})) + +vi.mock('#mcp/values/service.ts', () => ({ + listValues: (...args: Array) => mockModule.listValues(...args), + saveValue: (...args: Array) => mockModule.saveValue(...args), +})) + +const { valueListCapability } = await import('./value-list.ts') +const { valueSetCapability } = await import('./value-set.ts') + +function buildCallerContext() { + return createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { + userId: 'user-1', + email: 'user-1@example.com', + displayName: 'user-1', + }, + }) +} + +test('value_list omits platform-reserved integration and openapi values', async () => { + mockModule.listValues.mockResolvedValue([ + { + name: 'preferred_repo', + scope: 'user', + value: 'kentcdodds/kody', + description: 'Preferred repo', + appId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ttlMs: null, + }, + { + name: '_integration:github', + scope: 'user', + value: '{}', + description: 'GitHub OAuth integration', + appId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ttlMs: null, + }, + { + name: '_openapi:widgets', + scope: 'user', + value: '{}', + description: 'Widgets OpenAPI binding', + appId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ttlMs: null, + }, + ]) + + const result = await valueListCapability.handler( + {}, + { + env: {} as Env, + callerContext: buildCallerContext(), + }, + ) + + expect(result.values).toEqual([ + { + name: 'preferred_repo', + scope: 'user', + value: 'kentcdodds/kody', + description: 'Preferred repo', + app_id: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ttl_ms: null, + }, + ]) +}) + +test('value_set rejects platform-reserved prefixes with actionable errors', async () => { + const env = {} as Env + const callerContext = buildCallerContext() + + await expect( + valueSetCapability.handler( + { + name: '_integration:github', + value: '{}', + scope: 'user', + }, + { env, callerContext }, + ), + ).rejects.toThrow('Use integration_save instead of value_set.') + + await expect( + valueSetCapability.handler( + { + name: '_openapi:widgets', + value: '{}', + scope: 'user', + }, + { env, callerContext }, + ), + ).rejects.toThrow('Use openapi_binding_save instead of value_set.') + + expect(mockModule.saveValue).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/mcp/capabilities/values/value-list.ts b/packages/worker/src/mcp/capabilities/values/value-list.ts index 9bcb1fd6c0..fdafbf7cbe 100644 --- a/packages/worker/src/mcp/capabilities/values/value-list.ts +++ b/packages/worker/src/mcp/capabilities/values/value-list.ts @@ -3,7 +3,10 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { isReservedValueName } from '#mcp/values/value-name-guards.ts' +import { + isPlatformReservedValueName, + isReservedValueName, +} from '#mcp/values/value-name-guards.ts' import { listValues } from '#mcp/values/service.ts' import { valueScopeValues } from '#mcp/values/types.ts' import { valueMetadataSchema } from './shared.ts' @@ -43,7 +46,11 @@ export const valueListCapability = defineDomainCapability( }) return { values: values - .filter((value) => !isReservedValueName(value.name)) + .filter( + (value) => + !isReservedValueName(value.name) && + !isPlatformReservedValueName(value.name), + ) .map((value) => ({ name: value.name, scope: value.scope, diff --git a/packages/worker/src/mcp/capabilities/values/value-set.ts b/packages/worker/src/mcp/capabilities/values/value-set.ts index f426a11c98..5d669e9994 100644 --- a/packages/worker/src/mcp/capabilities/values/value-set.ts +++ b/packages/worker/src/mcp/capabilities/values/value-set.ts @@ -3,7 +3,7 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { assertValueNameAllowed } from '#mcp/values/value-name-guards.ts' +import { assertAgentWritableValueName } from '#mcp/values/value-name-guards.ts' import { saveValue } from '#mcp/values/service.ts' import { valueScopeValues } from '#mcp/values/types.ts' import { valueMetadataSchema } from './shared.ts' @@ -39,7 +39,7 @@ export const valueSetCapability = defineDomainCapability( async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) const name = args.name.trim() - assertValueNameAllowed(name) + assertAgentWritableValueName(name) const value = await saveValue({ env: ctx.env, userId: user.userId, diff --git a/packages/worker/src/mcp/tools/search-entity-plugins/value.ts b/packages/worker/src/mcp/tools/search-entity-plugins/value.ts index fc791fc21a..476aa8b562 100644 --- a/packages/worker/src/mcp/tools/search-entity-plugins/value.ts +++ b/packages/worker/src/mcp/tools/search-entity-plugins/value.ts @@ -1,5 +1,5 @@ import { lexicalScore } from '#mcp/capabilities/capability-search.ts' -import { parseIntegrationValueName } from '#mcp/capabilities/integrations/integration-shared.ts' +import { isPlatformReservedValueName } from '#mcp/values/value-name-guards.ts' import { buildValueEntityId, describeValue, @@ -16,7 +16,7 @@ import { buildCandidateBaseScore } from '../search-scoring.ts' export const valueSearchEntityPlugin = { type: 'value', buildValueRowDescriptor(row) { - if (parseIntegrationValueName(row.name)) return undefined + if (isPlatformReservedValueName(row.name)) return undefined return { type: 'value', id: buildValueEntityId(row), @@ -29,7 +29,7 @@ export const valueSearchEntityPlugin = { buildCandidates(input) { return input.optionalRows.userValueRows .flatMap((row) => { - if (parseIntegrationValueName(row.name)) return [] + if (isPlatformReservedValueName(row.name)) return [] const lexical = lexicalScore( input.query, [row.name, row.description, row.scope, row.value].join('\n'), diff --git a/packages/worker/src/mcp/values/service.node.test.ts b/packages/worker/src/mcp/values/service.node.test.ts index cf82928132..afab38d7fd 100644 --- a/packages/worker/src/mcp/values/service.node.test.ts +++ b/packages/worker/src/mcp/values/service.node.test.ts @@ -550,3 +550,34 @@ test('listValues ignores empty buckets and returns no values when none exist', a }), ).toEqual([]) }) + +test('saveValue permits platform-reserved openapi prefix for internal callers', async () => { + const testDb = createValueTestDb() + const env = { APP_DB: testDb.db } + + const saved = await saveValue({ + env, + userId: 'user-platform', + scope: 'user', + name: '_openapi:widgets', + value: '{"provider":"widgets"}', + description: 'Widgets OpenAPI binding', + }) + + expect(saved).toMatchObject({ + name: '_openapi:widgets', + value: '{"provider":"widgets"}', + scope: 'user', + }) + expect( + await getValue({ + env, + userId: 'user-platform', + name: '_openapi:widgets', + scope: 'user', + }), + ).toMatchObject({ + name: '_openapi:widgets', + value: '{"provider":"widgets"}', + }) +}) diff --git a/packages/worker/src/mcp/values/value-name-guards.node.test.ts b/packages/worker/src/mcp/values/value-name-guards.node.test.ts new file mode 100644 index 0000000000..1e6878b57c --- /dev/null +++ b/packages/worker/src/mcp/values/value-name-guards.node.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest' +import { + assertAgentWritableValueName, + assertValueNameAllowed, + isPlatformReservedValueName, +} from './value-name-guards.ts' + +test('isPlatformReservedValueName matches integration and openapi prefixes', () => { + expect(isPlatformReservedValueName('_integration:github')).toBe(true) + expect(isPlatformReservedValueName('_openapi:widgets')).toBe(true) + expect(isPlatformReservedValueName('preferred_repo')).toBe(false) + expect(isPlatformReservedValueName('_integration')).toBe(false) +}) + +test('assertAgentWritableValueName rejects platform prefixes with capability hints', () => { + expect(() => assertAgentWritableValueName('_integration:github')).toThrow( + 'Use integration_save instead of value_set.', + ) + expect(() => assertAgentWritableValueName('_openapi:widgets')).toThrow( + 'Use openapi_binding_save instead of value_set.', + ) + expect(() => assertAgentWritableValueName('user_config')).not.toThrow() +}) + +test('assertValueNameAllowed remains separate from platform prefix checks', () => { + expect(() => assertValueNameAllowed('_integration:github')).not.toThrow() + expect(() => assertAgentWritableValueName('_integration:github')).toThrow() +}) diff --git a/packages/worker/src/mcp/values/value-name-guards.ts b/packages/worker/src/mcp/values/value-name-guards.ts index 669fe7b123..aac767a454 100644 --- a/packages/worker/src/mcp/values/value-name-guards.ts +++ b/packages/worker/src/mcp/values/value-name-guards.ts @@ -1,11 +1,36 @@ const reservedValueNames = new Set() +const platformReservedValuePrefixes = ['_integration:', '_openapi:'] as const + +const platformReservedValuePrefixCapabilities = { + '_integration:': 'integration_save', + '_openapi:': 'openapi_binding_save', +} as const satisfies Record< + (typeof platformReservedValuePrefixes)[number], + string +> + export function isReservedValueName(name: string) { return reservedValueNames.has(name) } +export function isPlatformReservedValueName(name: string) { + return platformReservedValuePrefixes.some((prefix) => name.startsWith(prefix)) +} + export function assertValueNameAllowed(name: string) { if (isReservedValueName(name)) { throw new Error('Value name is reserved for internal use.') } } + +export function assertAgentWritableValueName(name: string) { + assertValueNameAllowed(name) + for (const prefix of platformReservedValuePrefixes) { + if (!name.startsWith(prefix)) continue + const capability = platformReservedValuePrefixCapabilities[prefix] + throw new Error( + `Value names with the ${prefix} prefix are managed by the ${capability} capability. Use ${capability} instead of value_set.`, + ) + } +} From 33d9aa23ad2a1b76ecd99f177acff26526bba732 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:41:47 +0000 Subject: [PATCH 07/15] docs(integrations): document the oauth app model and cover deletion inventory Both new tables join the account deletion and export inventory, connections before apps to respect the ON DELETE RESTRICT direction. The guardrail tests apply live migrations and fail on any uncovered user_id column. Add the integrations primitive, which the taxonomy never had, and correct docs/guides/oauth.md, which claimed integrations were stored as _integration: values. Co-authored-by: Kent C. Dodds --- .../contributing/architecture/data-storage.md | 16 +++ docs/contributing/architecture/index.md | 4 + .../contributing/architecture/integrations.md | 125 ++++++++++++++++++ .../contributing/architecture/primitives.yaml | 12 ++ docs/guides/oauth.md | 11 +- docs/use/search.md | 17 +-- .../worker/src/app/account-data-targets.ts | 5 + 7 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 docs/contributing/architecture/integrations.md diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md index 85d12f0c7e..178dd60da3 100644 --- a/docs/contributing/architecture/data-storage.md +++ b/docs/contributing/architecture/data-storage.md @@ -280,6 +280,15 @@ The schema is defined by migrations in `packages/worker/migrations/`: `allowed_packages` grant on every package read path. Updating or deleting a user secret from package code always requires that grant, regardless of fork or adoption state. +- `user_oauth_apps` (`0100-user-oauth-apps-and-integrations.sql`): per-user + OAuth app rows keyed by `(user_id, slug)`. Holds shared client id, client- + secret secret name, provider endpoints, and flow options. See + [OAuth integrations](./integrations.md). +- `user_integrations` (`0100-user-oauth-apps-and-integrations.sql`): per-user + OAuth connections keyed by `(user_id, name)`, with composite FK + `(user_id, app_slug) → user_oauth_apps(user_id, slug)` (`ON DELETE RESTRICT`). + Holds scopes, `requiredHosts`, and access/refresh token secret names. + Credential values stay in `secret_entries`. App access pattern: @@ -691,6 +700,13 @@ on write unless a migration backfills existing rows. Mutations from package code (`secret_set` / `secret_delete` / OpenAPI token-refresh writes) always require the grant. Package-scoped secrets are owned exclusively by the package id in their bucket binding. +- `user_oauth_apps.extra_authorize_params_json`, + `user_integrations.scopes_json`, and `user_integrations.required_hosts_json` + (`0100-user-oauth-apps-and-integrations.sql`, + `packages/worker/src/integrations/`) store a string→string object, a scope + string list, and a host string list respectively. Parsers in the integrations + data-access layer own the shapes; credential values are never stored in these + columns (only secret names and the inline non-secret `client_id`). ### Durable Object id contracts diff --git a/docs/contributing/architecture/index.md b/docs/contributing/architecture/index.md index 7b39de7850..7a5152c621 100644 --- a/docs/contributing/architecture/index.md +++ b/docs/contributing/architecture/index.md @@ -56,6 +56,10 @@ to become. - [OpenAPI provider bindings](./openapi-bindings.md): user-scoped curated OpenAPI bindings with runtime-synthesized `openapi:` domains callable via `kody.openapi[...]` (host approval never widened by untrusted specs). +- [OAuth integrations](./integrations.md): first-class OAuth apps and + connections in D1 (`user_oauth_apps` / `user_integrations`), secret-store + credential references, dual host gates, `/connect/oauth`, and + `createAuthenticatedFetch`. - [Local Agent Bridge Direction](./local-agent-bridge.md): proposed direction for securely reaching local-network systems through an outbound agent connection. diff --git a/docs/contributing/architecture/integrations.md b/docs/contributing/architecture/integrations.md new file mode 100644 index 0000000000..e3bb89abe8 --- /dev/null +++ b/docs/contributing/architecture/integrations.md @@ -0,0 +1,125 @@ +# OAuth integrations (apps and connections) + +Saved third-party OAuth config is a first-class per-user primitive: an **OAuth +app** (shared client credentials and provider endpoints) plus one or more +**connections** (connected accounts that share that app). Credential _values_ +never enter these tables — they live in the secret store and are referenced by +name. + +Data access lives under `packages/worker/src/integrations/`. MCP capabilities +live under `packages/worker/src/mcp/capabilities/integrations/`. The hosted +connect UI is `/connect/oauth`. + +## App / connection split + +An **OAuth app** (`user_oauth_apps`) holds the provider client identity and +endpoint config that many connections can share: + +- `client_id` (inline non-secret identifier) +- `client_secret_secret_name` (secret-store name, or null for public PKCE apps) +- `token_url`, optional `authorize_url` / `api_base_url` +- `flow` (`pkce` | `confidential`), optional `use_pkce`, `token_exchange_style`, + `scope_separator`, and `extra_authorize_params_json` + +A **connection** (`user_integrations`) is one connected account on that app: + +- `name` (canonical integration key agents pass to `createAuthenticatedFetch`) +- `app_slug` (composite FK to the owning app) +- `scopes_json`, `required_hosts_json` +- `access_token_secret_name` and optional `refresh_token_secret_name` +- optional `account_label` / `description`, plus connect/refresh timestamps + +The split exists so rotating client credentials is one write on the shared app +instead of one write per connection. Multiple Google accounts share one app; `x` +and `x-kodykoala` share one app when their app-level fields match. Two +connections that share a client id but reference different client-secret names +remain separate apps (for example `github` vs `github-kent`). + +Composite primary keys `(user_id, slug)` / `(user_id, name)` and the composite +FK `(user_id, app_slug) → user_oauth_apps(user_id, slug)` keep per-user +isolation structural. The connection FK uses `ON DELETE RESTRICT`, so +connections must be removed before their app. + +## Where credentials live + +| Field | Storage | Notes | +| ------------- | --------------------------- | ----------------------------------------- | +| Client id | `user_oauth_apps.client_id` | Non-secret OAuth client identifier | +| Client secret | Secret store | Referenced by `client_secret_secret_name` | +| Access token | Secret store | Referenced by `access_token_secret_name` | +| Refresh token | Secret store | Referenced by `refresh_token_secret_name` | + +Account export includes both tables. Rows contain secret _names_ and the inline +`client_id`, never encrypted secret payloads. Exporting `client_id` is +deliberate: it is not a credential value, and the same identifier was previously +portable as an ordinary user value. + +Leftover `-client-id` values in the values store (when present) are +ordinary user data left after migration; they are not the live source of truth +for integrations. + +## Two independent host gates + +Outbound OAuth calls enforce two allowlists that are not collapsed: + +1. **Connection `requiredHosts`** (plus the host of `apiBaseUrl`) — enforced by + `createAuthenticatedFetch` / `assertIntegrationHostAllowed` before an OAuth + bearer token is attached. A materialized token is not a `{{secret:…}}` + placeholder, so the fetch gateway cannot apply secret host policy to it. +2. **Each secret's `allowedHosts`** — enforced when secret placeholders resolve + through the fetch gateway (client secret on token exchange, token mounts, and + similar paths). + +Both must allow a destination for the full connect → refresh → API call path to +succeed. See the +[OAuth integration host allowlist](./index.md#oauth-integration-host-allowlist) +note on the architecture index. + +## `/connect/oauth` and authenticated fetch + +`/connect/oauth` runs authorize → callback → token exchange in the browser +session, writes access/refresh tokens through the account secrets flow, and +upserts the app + connection via the integrations service. Reconnect with +`?provider=` reuses saved authorize metadata (scopes, +`scopeSeparator`, `extraAuthorizeParams`) and the current app client +credentials. + +`createAuthenticatedFetch(providerName)` (execute runtime helper) loads the +named connection joined to its app, refreshes the access token when needed, and +returns a fetch wrapper that: + +1. asserts the request host against the connection allowlist +2. attaches the bearer token only after that check passes + +Capability and search detail surfaces keep a **flat connection-shaped** config +(`clientId`, secret names, endpoints, `authorization`, `requiredHosts`) so +callers do not need to join app and connection themselves. + +## Capability surface + +Domain: `integrations` +(`packages/worker/src/mcp/capabilities/integrations/domain.ts`). + +| Capability | Role | +| ------------------------------------------------------ | ------------------------------------------------------------- | +| `integration_save` / `_get` / `_list` / `_delete` | Connection CRUD with flat `clientId` output | +| `integration_oauth_app_list` | Apps with connection counts and sibling connection names | +| `integration_oauth_app_rotate_credentials` | Rotate shared app `clientId` / client-secret name | +| `integration_registry_search` / `integration_discover` | Untrusted integrations.sh research | +| `openapi_spec_summarize` / `openapi_client_scaffold` | Spec research helpers (bindings live in the `openapi` domain) | + +OpenAPI provider bindings (`_openapi:*` values-store config) are a separate +primitive; see [OpenAPI provider bindings](./openapi-bindings.md). + +## Account deletion order + +Deletion targets in `account-data-targets.ts` list `user_integrations` before +`user_oauth_apps` so the `ON DELETE RESTRICT` FK cannot block cleanup when +cascades are disabled. + +## Related docs + +- [Data storage](./data-storage.md) — D1 inventory and JSON shadow schemas +- [OAuth guide](../../guides/oauth.md) — agent-facing `/connect/oauth` workflow +- [Primitives map](./primitives.yaml) — `integrations` primitive and + `integration-host-allowlist` invariant diff --git a/docs/contributing/architecture/primitives.yaml b/docs/contributing/architecture/primitives.yaml index 5b2dc0098b..01a47e435f 100644 --- a/docs/contributing/architecture/primitives.yaml +++ b/docs/contributing/architecture/primitives.yaml @@ -259,6 +259,17 @@ primitives: - packages/worker/src/mcp/capabilities/secrets/ - packages/worker/src/security/ + - id: integrations + group: assistant + name: OAuth integrations + summary: + Per-user OAuth apps and connections; credentials stay in the secret store. + code: + - packages/worker/src/integrations/ + - packages/worker/src/mcp/capabilities/integrations/ + docs: + - docs/contributing/architecture/integrations.md + - id: memories group: assistant name: Memories @@ -537,6 +548,7 @@ invariants: summary: Enforce integration host allowlists before attaching OAuth tokens. docs: - docs/contributing/architecture/index.md + - docs/contributing/architecture/integrations.md - id: canonical-repo-source summary: Artifacts repos are canonical package/job source; D1 is metadata. diff --git a/docs/guides/oauth.md b/docs/guides/oauth.md index 7b8cf64a07..14e69e5d88 100644 --- a/docs/guides/oauth.md +++ b/docs/guides/oauth.md @@ -114,15 +114,18 @@ and the current client credentials. Integration identity is the canonical provider key: names are normalized to lowercase kebab (letters, numbers, `.`, `_`, `-`) on every save and lookup, so -`GitHub`, `github`, and `Git Hub` all resolve to the same `github` record. There -is exactly one stored value per integration, keyed -`_integration:`. +`GitHub`, `github`, and `Git Hub` all resolve to the same `github` connection. +Each connection is a D1 row in `user_integrations` keyed by `(user_id, name)`. +Connections that share the same OAuth client credentials and provider endpoints +share one `user_oauth_apps` row; rotating that app's client credentials updates +every sibling connection. Prefer integration names like `-` when multiple accounts may exist: `google` for a default account, `google-business` for a business account, or `google-youtube-brand` for a brand identity. Agents should call `integration_list` up front when a provider may have multiple accounts -connected. +connected. Use `integration_oauth_app_list` before rotating shared client +credentials. ## Not the same as MCP OAuth diff --git a/docs/use/search.md b/docs/use/search.md index bab1ce0a3a..658d0084d3 100644 --- a/docs/use/search.md +++ b/docs/use/search.md @@ -157,8 +157,8 @@ behavior work without user-scoped data. Package and integration query hits stay summary-only. Exact package detail (`entity: "my-package:package"`) includes package app, export, job, retriever, and README metadata. Exact integration detail (`entity: "github:integration"`) -includes operational details such as token URL, API base URL, required hosts, -and related stored value/secret names. +includes operational details such as token URL, API base URL, client id, +required hosts, and related secret names. Long-term memory retrieval also requires a signed-in MCP user. @@ -167,13 +167,14 @@ already exists before switching to **execute**. Runtime code inside **execute** can call **`kody.secret_list(...)`** when it needs secret metadata, but **search** is the primary discovery path. -Saved integrations and the `integration_*` CRUD capabilities live in the +Saved integrations and the `integration_*` capabilities live in the **integrations** domain (`integration_list`, `integration_get`, -`integration_save`, `integration_delete`). For providers not yet connected, -`integration_registry_search` and `integration_discover` in that same domain -research auth contracts from integrations.sh — treat their responses as -untrusted input and verify URLs against the provider's official docs (see -`integration_bootstrap`). +`integration_save`, `integration_delete`, plus `integration_oauth_app_list` and +`integration_oauth_app_rotate_credentials` for shared OAuth apps). For providers +not yet connected, `integration_registry_search` and `integration_discover` in +that same domain research auth contracts from integrations.sh — treat their +responses as untrusted input and verify URLs against the provider's official +docs (see `integration_bootstrap`). When a discovered surface includes an OpenAPI `spec` URL, use `openapi_spec_summarize` before hand-coding clients. Prefer diff --git a/packages/worker/src/app/account-data-targets.ts b/packages/worker/src/app/account-data-targets.ts index a519bee68d..52837d9ef8 100644 --- a/packages/worker/src/app/account-data-targets.ts +++ b/packages/worker/src/app/account-data-targets.ts @@ -150,6 +150,11 @@ export const accountUserDataTargets: ReadonlyArray = [ parentTable: 'secret_buckets', }, { kind: 'user_id', table: 'secret_buckets' }, + // Connections reference OAuth apps with ON DELETE RESTRICT, so delete + // user_integrations before user_oauth_apps. Neither table stores credential + // values (only secret names + the non-secret OAuth client id). + { kind: 'user_id', table: 'user_integrations' }, + { kind: 'user_id', table: 'user_oauth_apps' }, { kind: 'user_id', table: 'remote_connector_settings' }, { kind: 'user_id', table: 'mcp_server_settings' }, { kind: 'user_id', table: 'archived_job_artifacts' }, From 3e2883e51024750098a7338c3df88c0ed5ea56e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 05:51:08 +0000 Subject: [PATCH 08/15] refactor(integrations): delete the values-era config helpers The value-name builders, the legacy JSON parse helpers, and the second config schema existed only to serve callers that no longer exist. The value-name assertions in integration-save tests were the last references, and a test whose only subject is dead code is not coverage. One integration config schema remains, the one with an inline clientId, so there is no longer a 'with client id' variant to disambiguate. The migration test now builds its fixtures as literal historical JSON, which is what a migration test should assert against anyway rather than keeping a production schema alive to describe its own input. Also drop the E2E integration seeder. It has had no callers since #801 removed its last one, so it was already dead; rewriting it against the new tables would only create something to maintain. The _integration: prefix stays in the platform-reserved value guard so a shadowing value cannot be created later. Co-authored-by: Kent C. Dodds --- e2e/d1-utils.ts | 49 --------- .../src/app/account-integrations-data.ts | 4 +- .../src/app/handlers/account-secrets.ts | 8 +- .../src/integrations/migration.node.test.ts | 102 ++++++++++++------ packages/worker/src/integrations/service.ts | 26 ++--- .../integrations/integration-get.ts | 4 +- .../integrations/integration-list.ts | 4 +- .../integration-save.node.test.ts | 14 +-- .../integrations/integration-save.ts | 14 +-- .../integrations/integration-shared.ts | 77 +------------ .../openapi-provider/operation-request.ts | 10 +- .../tools/integration-package-suggestions.ts | 4 +- packages/worker/src/mcp/tools/search-core.ts | 6 +- .../search-entity-plugins/integration.ts | 6 +- .../src/mcp/tools/search-format-types.ts | 12 +-- 15 files changed, 125 insertions(+), 215 deletions(-) diff --git a/e2e/d1-utils.ts b/e2e/d1-utils.ts index fcf36597b4..3184c2fdfe 100644 --- a/e2e/d1-utils.ts +++ b/e2e/d1-utils.ts @@ -105,55 +105,6 @@ export async function seedSavedPackageInE2eDatabase(input: { ) } -export async function seedIntegrationInE2eDatabase(input: { - ownerEmail: string - name: string - tokenUrl: string - apiBaseUrl: string - requiredHosts: Array - scopes: Array -}) { - const ownerUserId = await createStableUserIdFromEmail(input.ownerEmail) - const bucketId = `e2e-integration-bucket-${ownerUserId}` - const valueName = `_integration:${input.name}` - const value = JSON.stringify({ - name: input.name, - tokenUrl: input.tokenUrl, - apiBaseUrl: input.apiBaseUrl, - flow: 'pkce', - clientIdValueName: `${input.name}-client-id`, - clientSecretSecretName: null, - accessTokenSecretName: `${input.name}-access-token`, - refreshTokenSecretName: `${input.name}-refresh-token`, - requiredHosts: input.requiredHosts, - authorization: { - authorizeUrl: `https://auth.${input.name}.example.com/authorize`, - scopes: input.scopes, - }, - }) - executeE2eD1Command( - `INSERT INTO value_buckets (id, user_id, scope, binding_key) - VALUES ( - ${quoteSqlString(bucketId)}, - ${quoteSqlString(ownerUserId)}, - 'user', - '' - ) - ON CONFLICT(user_id, scope, binding_key) DO UPDATE SET - updated_at = CURRENT_TIMESTAMP; - INSERT INTO value_entries (bucket_id, name, description, value) - VALUES ( - ${quoteSqlString(bucketId)}, - ${quoteSqlString(valueName)}, - ${quoteSqlString(`OAuth integration config for ${input.name}`)}, - ${quoteSqlString(value)} - ) - ON CONFLICT(bucket_id, name) DO UPDATE SET - value = excluded.value, - updated_at = CURRENT_TIMESTAMP;`, - ) -} - export function assignRoleInE2eDatabase(email: string, role: string) { executeE2eD1Command(buildRoleAssignmentSql({ email, role })) } diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index def7475a51..83ed8293b3 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -4,14 +4,14 @@ import { getJoinedIntegration, listJoinedIntegrations, toIntegrationConfig, - type IntegrationConfigWithClientId, + type IntegrationConfig, } from '#worker/integrations/service.ts' type AuthenticatedUser = NonNullable< Awaited> > -export type AccountIntegrationRecord = IntegrationConfigWithClientId & { +export type AccountIntegrationRecord = IntegrationConfig & { appSlug: string provider: string appLabel: string | null diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index ddc6f7c15e..ad4c313ba1 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -37,8 +37,8 @@ import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' import { getValue, saveValue } from '#mcp/values/service.ts' import { canonicalIntegrationName, - normalizeIntegrationConfigWithClientId, - integrationConfigWithClientIdSchema, + normalizeIntegrationConfig, + integrationConfigSchema, } from '#mcp/capabilities/integrations/integration-shared.ts' import { upsertIntegration } from '#worker/integrations/service.ts' import { requireAuthenticatedPageUser } from '#app/page-auth.ts' @@ -619,7 +619,7 @@ async function saveIntegrationConfig(input: { if (!providerKey) { throw new Error('Provider must contain letters or numbers.') } - const parsed = integrationConfigWithClientIdSchema.safeParse({ + const parsed = integrationConfigSchema.safeParse({ name: input.provider, tokenUrl: input.tokenUrl, apiBaseUrl: input.apiBaseUrl, @@ -644,7 +644,7 @@ async function saveIntegrationConfig(input: { const integration = await upsertIntegration({ env: input.env, userId: input.userId, - config: normalizeIntegrationConfigWithClientId(parsed.data), + config: normalizeIntegrationConfig(parsed.data), }) return integration.name } diff --git a/packages/worker/src/integrations/migration.node.test.ts b/packages/worker/src/integrations/migration.node.test.ts index f0e8302fa5..107047adb7 100644 --- a/packages/worker/src/integrations/migration.node.test.ts +++ b/packages/worker/src/integrations/migration.node.test.ts @@ -2,13 +2,49 @@ import { readdirSync, readFileSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { expect, test } from 'vitest' import { - parseIntegrationConfig, + normalizeIntegrationConfig, type IntegrationConfig, } from '#mcp/capabilities/integrations/integration-shared.ts' import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' import { getJoinedIntegrationByName } from './repo.ts' import { toIntegrationConfig } from './service.ts' +/** Historical `_integration:*` value blob shape (pre-0100), with client id as a sibling value name. */ +type LegacyIntegrationValue = { + name: string + tokenUrl?: string + apiBaseUrl?: string | null + flow: string + usePkce?: boolean | null + clientIdValueName: string + clientSecretSecretName?: string | null + accessTokenSecretName: string + refreshTokenSecretName?: string | null + requiredHosts?: Array + tokenExchangeStyle?: string | null + authorization?: { + authorizeUrl: string + scopes: Array + scopeSeparator?: string | null + extraAuthorizeParams?: Record + } | null +} + +function expectedConfigFromLegacy( + legacy: LegacyIntegrationValue, + clientId: string, +): IntegrationConfig { + const { clientIdValueName: _ignored, ...rest } = legacy + return normalizeIntegrationConfig({ + ...rest, + flow: rest.flow as IntegrationConfig['flow'], + tokenExchangeStyle: rest.tokenExchangeStyle as + | IntegrationConfig['tokenExchangeStyle'] + | undefined, + clientId, + }) +} + const migrationsDirectory = new URL('../../migrations/', import.meta.url) const oauthAppsMigration = '0100-user-oauth-apps-and-integrations.sql' @@ -71,18 +107,18 @@ function nScopes(count: number) { return Array.from({ length: count }, (_, index) => `scope.${index}`) } -function googleConfig(input: { +function googleLegacyConfig(input: { name: string scopeCount: number hostCount: number accessTokenSecretName: string refreshTokenSecretName: string -}) { +}): LegacyIntegrationValue { return { name: input.name, tokenUrl: 'https://oauth2.googleapis.com/token', apiBaseUrl: 'https://www.googleapis.com', - flow: 'pkce' as const, + flow: 'pkce', clientIdValueName: 'google-client-id', clientSecretSecretName: 'googleClientSecret', accessTokenSecretName: input.accessTokenSecretName, @@ -137,29 +173,29 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture value: 'google-client-id-value', }) - const googleFixtures = [ - googleConfig({ + const googleFixtures: Array = [ + googleLegacyConfig({ name: 'google', scopeCount: 11, hostCount: 10, accessTokenSecretName: 'googleAccessToken', refreshTokenSecretName: 'googleRefreshToken', }), - googleConfig({ + googleLegacyConfig({ name: 'google-calendar', scopeCount: 12, hostCount: 10, accessTokenSecretName: 'googleCalendarAccessToken', refreshTokenSecretName: 'googleCalendarRefreshToken', }), - googleConfig({ + googleLegacyConfig({ name: 'google-mail', scopeCount: 6, hostCount: 7, accessTokenSecretName: 'googleMailAccessToken', refreshTokenSecretName: 'googleMailRefreshToken', }), - googleConfig({ + googleLegacyConfig({ name: 'google-drive', scopeCount: 6, hostCount: 7, @@ -168,29 +204,29 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture }), ] - const github = { + const github: LegacyIntegrationValue = { name: 'github', tokenUrl: 'https://github.com/login/oauth/access_token', apiBaseUrl: 'https://api.github.com', - flow: 'confidential' as const, + flow: 'confidential', clientIdValueName: 'github-client-id', clientSecretSecretName: 'githubClientSecret', accessTokenSecretName: 'githubAccessToken', refreshTokenSecretName: null, requiredHosts: ['github.com', 'api.github.com'], } - const githubKent = { + const githubKent: LegacyIntegrationValue = { ...github, name: 'github-kent', clientSecretSecretName: 'githubKentClientSecret', accessTokenSecretName: 'githubKentAccessToken', } - const xConfig = { + const xConfig: LegacyIntegrationValue = { name: 'x', tokenUrl: 'https://api.twitter.com/2/oauth2/token', apiBaseUrl: 'https://api.twitter.com', - flow: 'pkce' as const, + flow: 'pkce', clientIdValueName: 'x-client-id', clientSecretSecretName: null, accessTokenSecretName: 'xAccessToken', @@ -203,7 +239,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture extraAuthorizeParams: {}, }, } - const xKodykoala = { + const xKodykoala: LegacyIntegrationValue = { ...xConfig, name: 'x-kodykoala', accessTokenSecretName: 'xKodykoalaAccessToken', @@ -216,11 +252,11 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture }, } - const tesla = { + const tesla: LegacyIntegrationValue = { name: 'tesla', tokenUrl: 'https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token', apiBaseUrl: 'https://fleet-api.prd.vn.cloud.tesla.com', - flow: 'pkce' as const, + flow: 'pkce', clientIdValueName: 'tesla-client-id', clientSecretSecretName: null, accessTokenSecretName: 'teslaAccessToken', @@ -228,6 +264,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture requiredHosts: ['fleet-api.prd.vn.cloud.tesla.com'], } + // Intentionally missing tokenUrl so 0100 leaves this non-migratable row alone. const malformed = { name: 'broken', apiBaseUrl: 'https://example.com', @@ -258,7 +295,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture value: JSON.stringify(malformed), }) - const userBGoogle = googleConfig({ + const userBGoogle = googleLegacyConfig({ name: 'google', scopeCount: 11, hostCount: 10, @@ -271,13 +308,13 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture value: JSON.stringify(userBGoogle), }) - const preMigrationByKey = new Map() + const expectedByKey = new Map() for (const config of [...userAConfigs, userBGoogle]) { - const parsed = parseIntegrationConfig(config, config.name) - expect(parsed).not.toBeNull() - if (!parsed) continue const userId = config === userBGoogle ? 'user-b' : 'user-a' - preMigrationByKey.set(`${userId}:${parsed.name}`, parsed) + expectedByKey.set( + `${userId}:${config.name}`, + expectedConfigFromLegacy(config, expectedClientId(config.name)), + ) } applyMigration(db, oauthAppsMigration) @@ -422,7 +459,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture ).toEqual(['google']) const d1 = createD1FromSqlite(db) - for (const [key, before] of preMigrationByKey) { + for (const [key, expected] of expectedByKey) { const [userId, name] = key.split(':') as [string, string] const joined = await getJoinedIntegrationByName({ db: d1, @@ -431,12 +468,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture }) expect(joined).not.toBeNull() if (!joined) continue - const after = toIntegrationConfig(joined.app, joined.connection) - const { clientIdValueName: _ignored, ...rest } = before - expect(after).toEqual({ - ...rest, - clientId: expectedClientId(name), - }) + expect(toIntegrationConfig(joined.app, joined.connection)).toEqual(expected) } const userAListed = await getJoinedIntegrationByName({ @@ -477,11 +509,11 @@ test('0100 splits apps when credentials match but token_url differs', async () = value: 'same-client-id', }) - const left = { + const left: LegacyIntegrationValue = { name: 'provider-alpha', tokenUrl: 'https://auth.example.com/oauth/token', apiBaseUrl: 'https://api.example.com', - flow: 'pkce' as const, + flow: 'pkce', clientIdValueName: 'shared-client-id', clientSecretSecretName: 'sharedClientSecret', accessTokenSecretName: 'alphaAccessToken', @@ -494,15 +526,17 @@ test('0100 splits apps when credentials match but token_url differs', async () = extraAuthorizeParams: {}, }, } - const right = { + const right: LegacyIntegrationValue = { ...left, name: 'provider-beta', tokenUrl: 'https://auth.example.com/oauth/v2/token', accessTokenSecretName: 'betaAccessToken', refreshTokenSecretName: 'betaRefreshToken', authorization: { - ...left.authorization, + authorizeUrl: 'https://auth.example.com/oauth/authorize', scopes: ['write'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, }, } diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 55e6ae3d44..25672670d0 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -1,8 +1,8 @@ import { canonicalIntegrationName, - integrationConfigWithClientIdSchema, - normalizeIntegrationConfigWithClientId, - type IntegrationConfigWithClientId, + integrationConfigSchema, + normalizeIntegrationConfig, + type IntegrationConfig, } from '#mcp/capabilities/integrations/integration-shared.ts' import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' import { @@ -26,12 +26,12 @@ import { type UserOauthAppWithConnectionCount, } from './types.ts' -export type { IntegrationConfigWithClientId } +export type { IntegrationConfig } export function toIntegrationConfig( app: UserOauthApp, connection: UserIntegrationConnection, -): IntegrationConfigWithClientId { +): IntegrationConfig { const authorization = app.authorizeUrl == null ? null @@ -41,7 +41,7 @@ export function toIntegrationConfig( scopeSeparator: app.scopeSeparator, extraAuthorizeParams: app.extraAuthorizeParams, } - return normalizeIntegrationConfigWithClientId({ + return normalizeIntegrationConfig({ name: connection.name, tokenUrl: app.tokenUrl, apiBaseUrl: app.apiBaseUrl, @@ -60,7 +60,7 @@ export function toIntegrationConfig( export async function listIntegrations(input: { env: Pick userId: string -}): Promise> { +}): Promise> { const rows = await listJoinedIntegrationsForUser({ db: input.env.APP_DB, userId: input.userId, @@ -82,7 +82,7 @@ export async function getIntegration(input: { env: Pick userId: string name: string -}): Promise { +}): Promise { const joined = await getJoinedIntegration({ env: input.env, userId: input.userId, @@ -109,12 +109,12 @@ export async function getJoinedIntegration(input: { export async function upsertIntegration(input: { env: Pick userId: string - config: IntegrationConfigWithClientId + config: IntegrationConfig description?: string | null accountLabel?: string | null -}): Promise { - const parsed = integrationConfigWithClientIdSchema.parse(input.config) - const config = normalizeIntegrationConfigWithClientId(parsed) +}): Promise { + const parsed = integrationConfigSchema.parse(input.config) + const config = normalizeIntegrationConfig(parsed) const now = new Date().toISOString() const existing = await getJoinedIntegrationByName({ db: input.env.APP_DB, @@ -398,7 +398,7 @@ export async function deleteOauthAppIfUnused(input: { function buildOauthAppRow(input: { userId: string slug: string - config: IntegrationConfigWithClientId + config: IntegrationConfig label: string | null createdAt: string updatedAt: string diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-get.ts b/packages/worker/src/mcp/capabilities/integrations/integration-get.ts index a64e5f179c..22b7b563f4 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-get.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-get.ts @@ -4,14 +4,14 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { getIntegration } from '#worker/integrations/service.ts' -import { integrationConfigWithClientIdSchema } from './integration-shared.ts' +import { integrationConfigSchema } from './integration-shared.ts' const inputSchema = z.object({ name: z.string().min(1).describe('Integration name to read.'), }) const outputSchema = z.object({ - integration: integrationConfigWithClientIdSchema.nullable(), + integration: integrationConfigSchema.nullable(), }) export const integrationGetCapability = defineDomainCapability( diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-list.ts b/packages/worker/src/mcp/capabilities/integrations/integration-list.ts index 0b95f3aca7..82486eb1a9 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-list.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-list.ts @@ -7,10 +7,10 @@ import { type CapabilityContext, } from '#mcp/capabilities/types.ts' import { listIntegrations } from '#worker/integrations/service.ts' -import { integrationConfigWithClientIdSchema } from './integration-shared.ts' +import { integrationConfigSchema } from './integration-shared.ts' const outputSchema = z.object({ - integrations: z.array(integrationConfigWithClientIdSchema), + integrations: z.array(integrationConfigSchema), }) export const integrationListCapability = defineDomainCapability( diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index b88362ffe9..aca419f68b 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -10,10 +10,8 @@ import { integrationOauthAppListCapability } from './integration-oauth-app-list. import { integrationOauthAppRotateCredentialsCapability } from './integration-oauth-app-rotate-credentials.ts' import { integrationSaveCapability } from './integration-save.ts' import { - buildIntegrationValueName, - integrationConfigWithClientIdSchema, + integrationConfigSchema, mergeIntegrationConfig, - parseIntegrationValueName, } from './integration-shared.ts' const migrationsDirectory = new URL('../../../../migrations/', import.meta.url) @@ -79,7 +77,7 @@ const googleBase = { } test('mergeIntegrationConfig and integration_save create a new app + connection with clientId shape', async () => { - const current = integrationConfigWithClientIdSchema.parse({ + const current = integrationConfigSchema.parse({ name: 'spotify', tokenUrl: 'https://accounts.spotify.com/api/token', apiBaseUrl: 'https://api.spotify.com/v1', @@ -428,14 +426,6 @@ test('integration identity is the canonical provider key across save and lookup' ) expect(got.integration?.name).toBe('github') - // Value-name helpers remain for concurrent migration callers. - expect(buildIntegrationValueName('GitHub')).toBe('_integration:github') - expect(buildIntegrationValueName('Spotify Family')).toBe( - '_integration:spotify-family', - ) - expect(parseIntegrationValueName('_integration:github')).toBe('github') - expect(parseIntegrationValueName('_integration:GitHub')).toBeNull() - await expect( integrationSaveCapability.handler( { diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts index ac782c4f7e..5d6e115ec7 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.ts @@ -6,19 +6,19 @@ import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { getIntegration, upsertIntegration, - type IntegrationConfigWithClientId, + type IntegrationConfig, } from '#worker/integrations/service.ts' import { - integrationConfigWithClientIdSchema, + integrationConfigSchema, integrationSaveSchema, mergeIntegrationConfig, - normalizeIntegrationConfigWithClientId, + normalizeIntegrationConfig, } from './integration-shared.ts' const inputSchema = integrationSaveSchema const outputSchema = z.object({ - integration: integrationConfigWithClientIdSchema, + integration: integrationConfigSchema, }) export const integrationSaveCapability = defineDomainCapability( @@ -64,8 +64,8 @@ export const integrationSaveCapability = defineDomainCapability( function createNewIntegrationConfig( args: z.infer, -): IntegrationConfigWithClientId { - const parsed = integrationConfigWithClientIdSchema.safeParse({ +): IntegrationConfig { + const parsed = integrationConfigSchema.safeParse({ name: args.name, tokenUrl: args.tokenUrl, apiBaseUrl: args.apiBaseUrl ?? null, @@ -94,5 +94,5 @@ function createNewIntegrationConfig( `Cannot create integration "${args.name}": missing or invalid required fields — ${details}`, ) } - return normalizeIntegrationConfigWithClientId(parsed.data) + return normalizeIntegrationConfig(parsed.data) } diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts index d7f72ddc37..189c3e3d28 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-shared.ts @@ -58,7 +58,7 @@ export const integrationConfigSchema = z.object({ * `usePkce: true` with `confidential` flow. */ usePkce: z.boolean().optional().nullable(), - clientIdValueName: z.string().min(1), + clientId: z.string().min(1), clientSecretSecretName: z.string().min(1).optional().nullable(), accessTokenSecretName: z.string().min(1), refreshTokenSecretName: z.string().min(1).optional().nullable(), @@ -92,30 +92,6 @@ export type IntegrationSaveInput = z.infer export function normalizeIntegrationConfig( value: IntegrationConfig, ): IntegrationConfig { - const normalized = normalizeIntegrationConfigFields(value) - return { - ...normalized, - clientIdValueName: value.clientIdValueName.trim(), - } -} - -/** - * Flat integration config with inline `clientId` (D1 / capability shape). - * Same as {@link IntegrationConfig} except `clientIdValueName` → `clientId`. - */ -export const integrationConfigWithClientIdSchema = integrationConfigSchema - .omit({ clientIdValueName: true }) - .extend({ - clientId: z.string().min(1), - }) - -export type IntegrationConfigWithClientId = z.infer< - typeof integrationConfigWithClientIdSchema -> - -export function normalizeIntegrationConfigWithClientId( - value: IntegrationConfigWithClientId, -): IntegrationConfigWithClientId { const normalized = normalizeIntegrationConfigFields(value) return { ...normalized, @@ -193,61 +169,16 @@ export function normalizeIntegrationAuthorization( } export function mergeIntegrationConfig( - current: IntegrationConfigWithClientId, + current: IntegrationConfig, update: IntegrationSaveInput, -): IntegrationConfigWithClientId { - return normalizeIntegrationConfigWithClientId({ +): IntegrationConfig { + return normalizeIntegrationConfig({ ...current, ...update, name: update.name, }) } -const integrationValuePrefix = '_integration:' - -export function buildIntegrationValueName(name: string) { - return `${integrationValuePrefix}${canonicalIntegrationName(name)}` -} - -export function parseIntegrationValueName(name: string) { - if (!name.startsWith(integrationValuePrefix)) return null - const integrationName = name.slice(integrationValuePrefix.length) - // Only canonical keys count as integrations; a non-canonical suffix cannot - // have been written by any current write path. - if ( - integrationName.length === 0 || - integrationName !== canonicalIntegrationName(integrationName) - ) { - return null - } - return integrationName -} - -export function parseIntegrationConfig( - value: unknown, - fallbackName: string | null, -) { - const record = - value && typeof value === 'object' && !Array.isArray(value) ? value : null - const configCandidate = - record && typeof (record as Record).name === 'string' - ? record - : record && fallbackName - ? { ...record, name: fallbackName } - : record - const parsed = integrationConfigSchema.safeParse(configCandidate) - if (parsed.success) return normalizeIntegrationConfig(parsed.data) - return null -} - -export function parseIntegrationJson(raw: string) { - try { - return JSON.parse(raw) - } catch { - return null - } -} - function isHttpUrl(raw: string) { try { const url = new URL(raw) diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts index f3bde67c0e..25ca3e61d4 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts @@ -14,7 +14,7 @@ import { import { type StorageContext } from '#mcp/storage.ts' import { getIntegration, - type IntegrationConfigWithClientId, + type IntegrationConfig, } from '#worker/integrations/service.ts' import { normalizeApiBaseUrl, @@ -62,7 +62,7 @@ export async function executeOpenApiOperationRequest(input: { }) assertPinnedToApiBaseUrl(url, input.binding.apiBaseUrl) - let integration: IntegrationConfigWithClientId | null = null + let integration: IntegrationConfig | null = null if (input.binding.auth.kind === 'integration') { integration = await loadIntegrationConfig({ env: input.env, @@ -206,7 +206,7 @@ async function loadIntegrationConfig(input: { env: Pick userId: string provider: string -}): Promise { +}): Promise { const integration = await getIntegration({ env: input.env, userId: input.userId, @@ -223,7 +223,7 @@ async function loadIntegrationConfig(input: { function buildRequestHeaders(input: { userHeaders: Record auth: OpenApiBinding['auth'] - integration: IntegrationConfigWithClientId | null + integration: IntegrationConfig | null requestBody: OpenApiBindingOperation['requestBody'] hasBody: boolean }) { @@ -348,7 +348,7 @@ async function tryRefreshIntegrationAccessToken(input: { userId: string baseUrl: string provider: string - integration: IntegrationConfigWithClientId + integration: IntegrationConfig storageContext: StorageContext | null globalFetch?: typeof fetch }): Promise<{ ok: boolean; guidance?: string }> { diff --git a/packages/worker/src/mcp/tools/integration-package-suggestions.ts b/packages/worker/src/mcp/tools/integration-package-suggestions.ts index f47b11937b..1bc0de7410 100644 --- a/packages/worker/src/mcp/tools/integration-package-suggestions.ts +++ b/packages/worker/src/mcp/tools/integration-package-suggestions.ts @@ -2,7 +2,7 @@ import { buildCommunityPublicUrl } from '#mcp/capabilities/community/shared.ts' import { getDomain } from 'tldts' import { canonicalIntegrationName, - type IntegrationConfigWithClientId, + type IntegrationConfig, } from '#mcp/capabilities/integrations/integration-shared.ts' import { searchCommunityListings } from '#worker/community/service.ts' import { type RelatedIntegrationPackageSuggestion } from './search-format.ts' @@ -12,7 +12,7 @@ export const maxIntegrationPackageSuggestions = 3 const communitySuggestionCandidateLimit = 12 type IntegrationProviderMetadata = Pick< - IntegrationConfigWithClientId, + IntegrationConfig, 'name' | 'tokenUrl' | 'apiBaseUrl' | 'requiredHosts' | 'authorization' > diff --git a/packages/worker/src/mcp/tools/search-core.ts b/packages/worker/src/mcp/tools/search-core.ts index 1885c27c92..d98aa64a57 100644 --- a/packages/worker/src/mcp/tools/search-core.ts +++ b/packages/worker/src/mcp/tools/search-core.ts @@ -306,7 +306,11 @@ export async function searchUnified(input: { } }), ) - const candidates = candidateResults.flatMap((result) => result.candidates) + // Annotate the flatMap callback: `as const` plugins infer distinct candidate + // array element types, and without this TS widens the union to `unknown[]`. + const candidates = candidateResults.flatMap( + (result): Array => result.candidates, + ) const candidateTimings: Pick< SearchPhaseTimings, 'capabilityCandidatesMs' | 'packageCandidatesMs' diff --git a/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts b/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts index 85321226d4..bab2e57f2b 100644 --- a/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts +++ b/packages/worker/src/mcp/tools/search-entity-plugins/integration.ts @@ -3,7 +3,7 @@ import { deterministicEmbedding, lexicalScore, } from '#mcp/capabilities/capability-search.ts' -import { type IntegrationConfigWithClientId } from '#mcp/capabilities/integrations/integration-shared.ts' +import { type IntegrationConfig } from '#mcp/capabilities/integrations/integration-shared.ts' import { toIntegrationConfig } from '#worker/integrations/service.ts' import { type SearchEntityPlugin } from '../search-entity-plugin.ts' @@ -21,7 +21,7 @@ import { extractSearchTokens } from '../understand-search-query.ts' function describeIntegration(input: { description: string - flow: IntegrationConfigWithClientId['flow'] + flow: IntegrationConfig['flow'] }) { return ( input.description.trim() || @@ -32,7 +32,7 @@ function describeIntegration(input: { export function buildIntegrationSearchDocument(input: { integrationName: string description: string - config: IntegrationConfigWithClientId + config: IntegrationConfig }): string { return [ input.integrationName, diff --git a/packages/worker/src/mcp/tools/search-format-types.ts b/packages/worker/src/mcp/tools/search-format-types.ts index dc24e11e57..4df0223b38 100644 --- a/packages/worker/src/mcp/tools/search-format-types.ts +++ b/packages/worker/src/mcp/tools/search-format-types.ts @@ -1,4 +1,4 @@ -import { type IntegrationConfigWithClientId } from '#mcp/capabilities/integrations/integration-shared.ts' +import { type IntegrationConfig } from '#mcp/capabilities/integrations/integration-shared.ts' import { type CapabilitySpec } from '#mcp/capabilities/types.ts' import { type SecretSearchRow } from '#mcp/secrets/types.ts' import { type ValueMetadata } from '#mcp/values/types.ts' @@ -226,7 +226,7 @@ export type SlimSearchMatch = clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null - authorization: IntegrationConfigWithClientId['authorization'] | null + authorization: IntegrationConfig['authorization'] | null nextStep?: string } | { @@ -369,7 +369,7 @@ export type SearchEntityDetailStructured = title: string description: string usage: string - flow: IntegrationConfigWithClientId['flow'] + flow: IntegrationConfig['flow'] tokenUrl: string apiBaseUrl: string | null clientId: string @@ -377,7 +377,7 @@ export type SearchEntityDetailStructured = accessTokenSecretName: string refreshTokenSecretName: string | null requiredHosts: Array - authorization: IntegrationConfigWithClientId['authorization'] | null + authorization: IntegrationConfig['authorization'] | null relatedPackageSuggestions?: Array } @@ -421,7 +421,7 @@ export type SearchEntityDetail = id: string title: string description: string - config: IntegrationConfigWithClientId + config: IntegrationConfig relatedPackageSuggestions?: Array } @@ -484,7 +484,7 @@ export type SearchMatch = clientSecretSecretName: string | null accessTokenSecretName: string refreshTokenSecretName: string | null - authorization?: IntegrationConfigWithClientId['authorization'] | null + authorization?: IntegrationConfig['authorization'] | null } | { type: 'secret' From d31ec81611650690933d84a415f0a83747b585f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 06:04:39 +0000 Subject: [PATCH 09/15] fix(integrations): preserve non-canonical integration value names in the backfill A value named literally `_integration:` passed the migratable filter and produced an empty app slug and connection name, then had its source row deleted. The old parseIntegrationValueName rejected empty and non-canonical names, so this was a regression against the previous validation. Require a non-empty canonical suffix in every copy of the migratable predicate, so those rows survive as values instead. Capture, delete, and remain predicates stay identical, which is the property that guarantees the delete can never outrun the insert. Also make the staging tables restart-safe, and cover the cases that matter if this ever goes wrong: an assertion firing must leave every _integration:* row in place, and an integration whose client id value is missing must be neither migrated nor deleted. Co-authored-by: Kent C. Dodds --- .../0100-user-oauth-apps-and-integrations.sql | 80 +++++- .../src/integrations/migration.node.test.ts | 230 ++++++++++++++++++ tools/migration-ledger.json | 2 +- 3 files changed, 301 insertions(+), 11 deletions(-) diff --git a/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql b/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql index f04aad5a57..d05e5077d4 100644 --- a/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql +++ b/packages/worker/migrations/0100-user-oauth-apps-and-integrations.sql @@ -15,10 +15,44 @@ -- full app-level tuple (credentials + endpoints + flow options). Connections -- merge into one app only when they agree on every app-level field; any -- disagreement yields separate apps so no config is silently rewritten. --- App slug is min(connection_name) per group; connection names are unique per --- user and each connection is in exactly one group, so those minima cannot --- collide. Then deletes only the migrated integration value rows. Client-id --- values and any non-migratable `_integration:*` rows are left untouched. +-- +-- Slug collision cannot occur: app slug is min(connection_name) per group, each +-- connection belongs to exactly one group, and connection names are already +-- unique per user. value_buckets has UNIQUE(user_id, scope, binding_key) and +-- user-scope rows always use binding_key = '', so there is exactly one +-- user-scope bucket per user; value_entries is keyed (bucket_id, name). +-- Therefore (user_id, name) is unique for user-scope `_integration:*` rows. +-- +-- Then deletes only the migrated integration value rows. Client-id values and +-- any non-migratable `_integration:*` rows are left untouched. +-- +-- Migratable name/value predicate (must stay identical across capture INSERT, +-- DELETE, and post-delete assertion — only the entry alias differs): +-- .name LIKE '\_integration:%' ESCAPE '\' +-- AND length(substr(.name, 14)) > 0 +-- AND substr(.name, 14) = trim(substr(.name, 14)) +-- AND substr(.name, 14) = lower(substr(.name, 14)) +-- AND substr(.name, 14) NOT LIKE '-%' +-- AND substr(.name, 14) NOT LIKE '%-' +-- AND substr(.name, 14) NOT GLOB '*[^a-z0-9._-]*' +-- AND substr(.name, 14) GLOB '*[a-z0-9]*' +-- AND json_valid(.value) +-- AND json_extract(.value, '$.tokenUrl') IS NOT NULL +-- AND trim(json_extract(.value, '$.tokenUrl')) != '' +-- AND json_extract(.value, '$.accessTokenSecretName') IS NOT NULL +-- AND trim(json_extract(.value, '$.accessTokenSecretName')) != '' +-- AND json_extract(.value, '$.flow') IN ('pkce', 'confidential') +-- AND trim(cid.value) != '' +-- AND ( +-- json_extract(.value, '$.tokenExchangeStyle') IS NULL +-- OR json_extract(.value, '$.tokenExchangeStyle') IN ( +-- 'form', 'basic-json', 'basic-form' +-- ) +-- ) +-- The name checks mirror parseIntegrationValueName / normalizeProviderKey: +-- non-empty canonical lowercase kebab (no leading/trailing '-', only +-- [a-z0-9._-], at least one alphanumeric). Empty `_integration:` and +-- non-canonical suffixes survive as values. CREATE TABLE IF NOT EXISTS user_oauth_apps ( user_id TEXT NOT NULL, @@ -64,8 +98,13 @@ ON user_integrations(user_id, app_slug); CREATE INDEX IF NOT EXISTS idx_user_oauth_apps_user_provider ON user_oauth_apps(user_id, provider); +-- Restart-safe staging: drop leftovers from a prior failed attempt, then recreate. +DROP TABLE IF EXISTS __migration_0100_source; +DROP TABLE IF EXISTS __migration_0100_app_groups; +DROP TABLE IF EXISTS __migration_assertions; + -- Staging table so app insert + connection insert share one grouping definition. -CREATE TABLE __migration_0100_source ( +CREATE TABLE IF NOT EXISTS __migration_0100_source ( user_id TEXT NOT NULL, connection_name TEXT NOT NULL, description TEXT NOT NULL, @@ -142,6 +181,13 @@ INNER JOIN value_entries cid AND cid.name = json_extract(e.value, '$.clientIdValueName') WHERE b.scope = 'user' AND e.name LIKE '\_integration:%' ESCAPE '\' + AND length(substr(e.name, 14)) > 0 + AND substr(e.name, 14) = trim(substr(e.name, 14)) + AND substr(e.name, 14) = lower(substr(e.name, 14)) + AND substr(e.name, 14) NOT LIKE '-%' + AND substr(e.name, 14) NOT LIKE '%-' + AND substr(e.name, 14) NOT GLOB '*[^a-z0-9._-]*' + AND substr(e.name, 14) GLOB '*[a-z0-9]*' AND json_valid(e.value) AND json_extract(e.value, '$.tokenUrl') IS NOT NULL AND trim(json_extract(e.value, '$.tokenUrl')) != '' @@ -158,7 +204,7 @@ WHERE b.scope = 'user' ) ); -CREATE TABLE __migration_0100_app_groups ( +CREATE TABLE IF NOT EXISTS __migration_0100_app_groups ( user_id TEXT NOT NULL, slug TEXT NOT NULL, client_id TEXT NOT NULL, @@ -302,7 +348,7 @@ INNER JOIN __migration_0100_app_groups g AND s.extra_authorize_params_json IS g.extra_authorize_params_json; -- __migration_assertions intentionally uses CHECK (0) as an unreachable trap. -CREATE TABLE __migration_assertions ( +CREATE TABLE IF NOT EXISTS __migration_assertions ( message TEXT NOT NULL CHECK (0) ); @@ -359,6 +405,13 @@ WHERE EXISTS ( WHERE b.id = value_entries.bucket_id AND b.scope = 'user' AND value_entries.name LIKE '\_integration:%' ESCAPE '\' + AND length(substr(value_entries.name, 14)) > 0 + AND substr(value_entries.name, 14) = trim(substr(value_entries.name, 14)) + AND substr(value_entries.name, 14) = lower(substr(value_entries.name, 14)) + AND substr(value_entries.name, 14) NOT LIKE '-%' + AND substr(value_entries.name, 14) NOT LIKE '%-' + AND substr(value_entries.name, 14) NOT GLOB '*[^a-z0-9._-]*' + AND substr(value_entries.name, 14) GLOB '*[a-z0-9]*' AND json_valid(value_entries.value) AND json_extract(value_entries.value, '$.tokenUrl') IS NOT NULL AND trim(json_extract(value_entries.value, '$.tokenUrl')) != '' @@ -388,6 +441,13 @@ WHERE EXISTS ( AND cid.name = json_extract(e.value, '$.clientIdValueName') WHERE b.scope = 'user' AND e.name LIKE '\_integration:%' ESCAPE '\' + AND length(substr(e.name, 14)) > 0 + AND substr(e.name, 14) = trim(substr(e.name, 14)) + AND substr(e.name, 14) = lower(substr(e.name, 14)) + AND substr(e.name, 14) NOT LIKE '-%' + AND substr(e.name, 14) NOT LIKE '%-' + AND substr(e.name, 14) NOT GLOB '*[^a-z0-9._-]*' + AND substr(e.name, 14) GLOB '*[a-z0-9]*' AND json_valid(e.value) AND json_extract(e.value, '$.tokenUrl') IS NOT NULL AND trim(json_extract(e.value, '$.tokenUrl')) != '' @@ -405,6 +465,6 @@ WHERE EXISTS ( ) ); -DROP TABLE __migration_assertions; -DROP TABLE __migration_0100_app_groups; -DROP TABLE __migration_0100_source; +DROP TABLE IF EXISTS __migration_assertions; +DROP TABLE IF EXISTS __migration_0100_app_groups; +DROP TABLE IF EXISTS __migration_0100_source; diff --git a/packages/worker/src/integrations/migration.node.test.ts b/packages/worker/src/integrations/migration.node.test.ts index 107047adb7..80800c2bf6 100644 --- a/packages/worker/src/integrations/migration.node.test.ts +++ b/packages/worker/src/integrations/migration.node.test.ts @@ -60,6 +60,33 @@ function applyMigration(db: DatabaseSync, fileName: string) { db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) } +/** D1 wraps each migration file in a transaction. */ +function applyMigrationLikeD1(db: DatabaseSync, fileName: string) { + const sql = readFileSync(new URL(fileName, migrationsDirectory), 'utf8') + db.exec('BEGIN') + try { + db.exec(sql) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +function listIntegrationValueNames(db: DatabaseSync, userId: string) { + return ( + db + .prepare( + `SELECT e.name + FROM value_entries e + INNER JOIN value_buckets b ON b.id = e.bucket_id + WHERE b.user_id = ? AND e.name GLOB '_integration:*' + ORDER BY e.name ASC`, + ) + .all(userId) as Array<{ name: string }> + ).map((row) => row.name) +} + function insertUserBucket( db: DatabaseSync, input: { bucketId: string; userId: string }, @@ -611,3 +638,206 @@ test('0100 splits apps when credentials match but token_url differs', async () = 'https://auth.example.com/oauth/v2/token', ) }) + +test('0100 capture/delete/remain predicates stay alias-equivalent', () => { + const sql = readFileSync( + new URL(oauthAppsMigration, migrationsDirectory), + 'utf8', + ) + const normalizePredicate = (block: string, entryAlias: string) => + block.replaceAll(entryAlias, '').replace(/\s+/g, ' ').trim() + + const captureMatch = + /INSERT INTO __migration_0100_source \([\s\S]*?WHERE (?b\.scope = 'user'[\s\S]*?);/.exec( + sql, + ) + const deleteMatch = + /DELETE FROM value_entries\nWHERE EXISTS \([\s\S]*?WHERE b\.id = value_entries\.bucket_id\n\t\tAND (?b\.scope = 'user'[\s\S]*?)\n\);/.exec( + sql, + ) + const remainMatch = + /migratable _integration:\* value rows remain after backfill[\s\S]*?WHERE (?b\.scope = 'user'[\s\S]*?)\n\);/.exec( + sql, + ) + + expect(captureMatch?.groups?.predicate).toBeTruthy() + expect(deleteMatch?.groups?.predicate).toBeTruthy() + expect(remainMatch?.groups?.predicate).toBeTruthy() + + const capture = normalizePredicate(captureMatch!.groups!.predicate, 'e.') + const deleted = normalizePredicate( + deleteMatch!.groups!.predicate, + 'value_entries.', + ) + const remain = normalizePredicate(remainMatch!.groups!.predicate, 'e.') + + expect(capture).toBe(deleted) + expect(capture).toBe(remain) + expect(capture).toContain("LIKE '\\_integration:%' ESCAPE '\\'") + expect(capture).toContain('length(substr(name, 14)) > 0') + expect(capture).toContain("GLOB '*[a-z0-9]*'") +}) + +test('0100 leaves empty-name and unresolved-clientId _integration values untouched', () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-survive', userId: 'user-survive' }) + insertValue(db, { + bucketId: 'bucket-survive', + name: 'real-client-id', + value: 'real-client-id-value', + }) + + const migratable = { + name: 'ok', + tokenUrl: 'https://auth.example.com/oauth/token', + apiBaseUrl: 'https://api.example.com', + flow: 'pkce', + clientIdValueName: 'real-client-id', + clientSecretSecretName: null, + accessTokenSecretName: 'okAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['api.example.com'], + } + const emptyName = { + ...migratable, + name: '', + accessTokenSecretName: 'emptyAccessToken', + } + const missingClientId = { + ...migratable, + name: 'missing-client', + clientIdValueName: 'does-not-exist', + accessTokenSecretName: 'missingAccessToken', + } + const emptyClientId = { + ...migratable, + name: 'empty-client', + clientIdValueName: 'blank-client-id', + accessTokenSecretName: 'blankAccessToken', + } + + insertValue(db, { + bucketId: 'bucket-survive', + name: 'blank-client-id', + value: ' ', + }) + insertValue(db, { + bucketId: 'bucket-survive', + name: '_integration:ok', + value: JSON.stringify(migratable), + }) + insertValue(db, { + bucketId: 'bucket-survive', + name: '_integration:', + value: JSON.stringify(emptyName), + }) + insertValue(db, { + bucketId: 'bucket-survive', + name: '_integration:missing-client', + value: JSON.stringify(missingClientId), + }) + insertValue(db, { + bucketId: 'bucket-survive', + name: '_integration:empty-client', + value: JSON.stringify(emptyClientId), + }) + insertValue(db, { + bucketId: 'bucket-survive', + name: '_integration:GitHub', + value: JSON.stringify({ ...migratable, name: 'GitHub' }), + }) + + applyMigrationLikeD1(db, oauthAppsMigration) + + expect(listIntegrationValueNames(db, 'user-survive')).toEqual([ + '_integration:', + '_integration:GitHub', + '_integration:empty-client', + '_integration:missing-client', + ]) + expect( + db + .prepare( + `SELECT name FROM user_integrations + WHERE user_id = 'user-survive' + ORDER BY name ASC`, + ) + .all(), + ).toEqual([{ name: 'ok' }]) + expect( + db + .prepare( + `SELECT slug FROM user_oauth_apps + WHERE user_id = 'user-survive' + ORDER BY slug ASC`, + ) + .all(), + ).toEqual([{ slug: 'ok' }]) +}) + +test('0100 assertion abort rolls back and preserves _integration value rows', () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-abort', userId: 'user-abort' }) + insertValue(db, { + bucketId: 'bucket-abort', + name: 'abort-client-id', + value: 'abort-client-id-value', + }) + insertValue(db, { + bucketId: 'bucket-abort', + name: '_integration:abort-me', + value: JSON.stringify({ + name: 'abort-me', + tokenUrl: 'https://auth.example.com/oauth/token', + apiBaseUrl: 'https://api.example.com', + flow: 'pkce', + clientIdValueName: 'abort-client-id', + clientSecretSecretName: null, + accessTokenSecretName: 'abortAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['api.example.com'], + }), + }) + + const beforeIntegrationValues = listIntegrationValueNames(db, 'user-abort') + expect(beforeIntegrationValues).toEqual(['_integration:abort-me']) + + // Force the post-delete "migratable rows remain" assertion to fire by + // ignoring deletes of `_integration:*` values. Created outside the + // migration transaction so the ignore survives until rollback. + db.exec(` + CREATE TRIGGER test_keep_integration_values + BEFORE DELETE ON value_entries + WHEN OLD.name GLOB '_integration:*' + BEGIN + SELECT RAISE(IGNORE); + END; + `) + + expect(() => applyMigrationLikeD1(db, oauthAppsMigration)).toThrow( + /CHECK constraint failed: 0/, + ) + + expect(listIntegrationValueNames(db, 'user-abort')).toEqual( + beforeIntegrationValues, + ) + expect( + db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' + AND name IN ( + 'user_oauth_apps', + 'user_integrations', + '__migration_0100_source', + '__migration_0100_app_groups', + '__migration_assertions' + )`, + ) + .all(), + ).toEqual([]) +}) diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index 4f466674e5..dc3305a220 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -426,7 +426,7 @@ }, { "filename": "0100-user-oauth-apps-and-integrations.sql", - "sha256": "aecded421eb34155609388c1fdbf9738cee91dc677c558bed5ae73d84cf12489" + "sha256": "d26af3d1f535d8329dc711ad367d5dfa5bf484046fd48c1f8df83f4093668a5d" } ] } From 11fe32632b20082c08c2a7880586f5644c27bbbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 06:24:12 +0000 Subject: [PATCH 10/15] fix(integrations): store canonical use_pkce and canonicalize app slugs The backfill wrote use_pkce = 0 for an explicit usePkce: false, but the application layer can never produce that row: PKCE-off is already the default for confidential flow, so normalizeIntegrationConfig omits the field and writes NULL. findOauthAppByAppTuple compares with IS, so a migrated 0 never matched a freshly normalized NULL and an app that should have been reused was not, letting a reconnect create a duplicate app or move a connection off the app its siblings share. The backfill now applies the same omit-when-default rule, so there is one canonical on-disk representation. The round-trip test could not catch this because toIntegrationConfig normalizes on read, so the config compared equal while the stored row did not; the new test asserts stored row values directly. Also canonicalize slugs on every oauth app path. They were only trimmed, so an agent passing Google got 'not found' while integration_get('Google') resolved, contradicting the rule that no lookup depends on caller casing. Co-authored-by: Kent C. Dodds --- .../0101-user-oauth-apps-and-integrations.sql | 6 + .../src/integrations/migration.node.test.ts | 88 ++++++++++ .../src/integrations/service.node.test.ts | 152 ++++++++++++++++++ packages/worker/src/integrations/service.ts | 10 +- tools/migration-ledger.json | 2 +- 5 files changed, 254 insertions(+), 4 deletions(-) diff --git a/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql b/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql index d05e5077d4..c0d6209c58 100644 --- a/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql +++ b/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql @@ -159,8 +159,14 @@ SELECT json_extract(e.value, '$.authorization.authorizeUrl'), NULLIF(trim(json_extract(e.value, '$.apiBaseUrl')), ''), json_extract(e.value, '$.flow'), + -- Match normalizeIntegrationConfig: store usePkce only when it differs + -- from the flow default (pkce→on, confidential→off). Otherwise NULL so + -- findOauthAppByAppTuple matches application-layer upserts. CASE WHEN json_extract(e.value, '$.usePkce') IS NULL THEN NULL + WHEN json_extract(e.value, '$.usePkce') = ( + json_extract(e.value, '$.flow') = 'pkce' + ) THEN NULL WHEN json_extract(e.value, '$.usePkce') = 0 THEN 0 ELSE 1 END, diff --git a/packages/worker/src/integrations/migration.node.test.ts b/packages/worker/src/integrations/migration.node.test.ts index ce5be61c0f..e5e5b51fb6 100644 --- a/packages/worker/src/integrations/migration.node.test.ts +++ b/packages/worker/src/integrations/migration.node.test.ts @@ -841,3 +841,91 @@ test('0100 assertion abort rolls back and preserves _integration value rows', () .all(), ).toEqual([]) }) + +test('0101 stores use_pkce matching normalizeIntegrationConfig flow defaults', () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-pkce', userId: 'user-pkce' }) + insertValue(db, { + bucketId: 'bucket-pkce', + name: 'shared-client-id', + value: 'shared-client-id-value', + }) + + const cases: Array<{ + name: string + flow: 'pkce' | 'confidential' + usePkce: boolean + expectedUsePkce: number | null + }> = [ + { + name: 'flow-pkce-default-on', + flow: 'pkce', + usePkce: true, + expectedUsePkce: null, + }, + { + name: 'flow-pkce-explicit-off', + flow: 'pkce', + usePkce: false, + expectedUsePkce: 0, + }, + { + name: 'flow-confidential-default-off', + flow: 'confidential', + usePkce: false, + expectedUsePkce: null, + }, + { + name: 'flow-confidential-explicit-on', + flow: 'confidential', + usePkce: true, + expectedUsePkce: 1, + }, + ] + + for (const entry of cases) { + insertValue(db, { + bucketId: 'bucket-pkce', + name: `_integration:${entry.name}`, + value: JSON.stringify({ + name: entry.name, + tokenUrl: 'https://auth.example.com/oauth/token', + apiBaseUrl: 'https://api.example.com', + flow: entry.flow, + usePkce: entry.usePkce, + clientIdValueName: 'shared-client-id', + clientSecretSecretName: `${entry.name}Secret`, + accessTokenSecretName: `${entry.name}AccessToken`, + refreshTokenSecretName: null, + requiredHosts: ['api.example.com'], + }), + }) + } + + applyMigration(db, oauthAppsMigration) + + const rows = db + .prepare( + `SELECT slug, flow, use_pkce + FROM user_oauth_apps + WHERE user_id = 'user-pkce' + ORDER BY slug ASC`, + ) + .all() as Array<{ + slug: string + flow: string + use_pkce: number | null + }> + + expect(rows).toEqual( + cases + .map((entry) => ({ + slug: entry.name, + flow: entry.flow, + use_pkce: entry.expectedUsePkce, + })) + .sort((left, right) => left.slug.localeCompare(right.slug)), + ) +}) diff --git a/packages/worker/src/integrations/service.node.test.ts b/packages/worker/src/integrations/service.node.test.ts index b78c9d457b..ca9800b6cc 100644 --- a/packages/worker/src/integrations/service.node.test.ts +++ b/packages/worker/src/integrations/service.node.test.ts @@ -5,13 +5,16 @@ import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.t import { deleteOauthAppIfUnused, getIntegration, + getOauthApp, listIntegrations, listOauthApps, + listJoinedIntegrations, rotateOauthAppClientCredentials, upsertIntegration, } from './service.ts' const migrationsDirectory = new URL('../../migrations/', import.meta.url) +const oauthAppsMigration = '0101-user-oauth-apps-and-integrations.sql' function applyAllMigrations(db: DatabaseSync) { for (const fileName of readdirSync(migrationsDirectory) @@ -21,6 +24,14 @@ function applyAllMigrations(db: DatabaseSync) { } } +function applyMigrationsBefore(db: DatabaseSync, exclusiveUpperBound: string) { + for (const fileName of readdirSync(migrationsDirectory) + .filter((file) => file.endsWith('.sql') && file < exclusiveUpperBound) + .sort()) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) + } +} + function createEnv() { const sqlite = new DatabaseSync(':memory:') applyAllMigrations(sqlite) @@ -189,3 +200,144 @@ test('deleteOauthAppIfUnused is blocked while connections exist', async () => { const stillThere = await getIntegration({ env, userId, name: 'google' }) expect(stillThere?.name).toBe('google') }) + +test('upsertIntegration reuses a migrated confidential app that stored usePkce false as NULL', async () => { + const sqlite = new DatabaseSync(':memory:') + applyMigrationsBefore(sqlite, oauthAppsMigration) + sqlite + .prepare( + `INSERT INTO value_buckets ( + id, user_id, scope, binding_key, expires_at, created_at, updated_at + ) VALUES (?, ?, 'user', '', NULL, ?, ?)`, + ) + .run( + 'bucket-reuse', + 'user-reuse', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', + ) + sqlite + .prepare( + `INSERT INTO value_entries ( + bucket_id, name, description, value, created_at, updated_at + ) VALUES (?, ?, '', ?, ?, ?)`, + ) + .run( + 'bucket-reuse', + 'canva-client-id', + 'canva-client-id-value', + '2026-02-01T00:00:00.000Z', + '2026-02-02T00:00:00.000Z', + ) + sqlite + .prepare( + `INSERT INTO value_entries ( + bucket_id, name, description, value, created_at, updated_at + ) VALUES (?, ?, '', ?, ?, ?)`, + ) + .run( + 'bucket-reuse', + '_integration:canva', + JSON.stringify({ + name: 'canva', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com', + flow: 'confidential', + usePkce: false, + clientIdValueName: 'canva-client-id', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaAccessToken', + refreshTokenSecretName: 'canvaRefreshToken', + requiredHosts: ['api.canva.com'], + tokenExchangeStyle: 'basic-form', + }), + '2026-02-01T00:00:00.000Z', + '2026-02-02T00:00:00.000Z', + ) + sqlite.exec( + readFileSync(new URL(oauthAppsMigration, migrationsDirectory), 'utf8'), + ) + + const stored = sqlite + .prepare( + `SELECT slug, flow, use_pkce FROM user_oauth_apps WHERE user_id = ?`, + ) + .get('user-reuse') as { + slug: string + flow: string + use_pkce: number | null + } + expect(stored).toEqual({ + slug: 'canva', + flow: 'confidential', + use_pkce: null, + }) + + const env = { APP_DB: createD1FromSqlite(sqlite) } as Pick + await upsertIntegration({ + env, + userId: 'user-reuse', + config: { + name: 'canva-team', + tokenUrl: 'https://api.canva.com/rest/v1/oauth/token', + apiBaseUrl: 'https://api.canva.com', + flow: 'confidential', + usePkce: false, + clientId: 'canva-client-id-value', + clientSecretSecretName: 'canvaClientSecret', + accessTokenSecretName: 'canvaTeamAccessToken', + refreshTokenSecretName: 'canvaTeamRefreshToken', + requiredHosts: ['api.canva.com'], + tokenExchangeStyle: 'basic-form', + }, + }) + + const apps = await listOauthApps({ env, userId: 'user-reuse' }) + expect(apps).toHaveLength(1) + expect(apps[0]).toMatchObject({ + slug: 'canva', + connectionCount: 2, + usePkce: null, + flow: 'confidential', + }) + const joined = await listJoinedIntegrations({ env, userId: 'user-reuse' }) + expect(joined.map(({ connection }) => connection.name).sort()).toEqual([ + 'canva', + 'canva-team', + ]) + expect(joined.every(({ app }) => app.slug === 'canva')).toBe(true) +}) + +test('getOauthApp and rotateOauthAppClientCredentials canonicalize mixed-case slugs', async () => { + const { env } = createEnv() + const userId = 'user-slug-case' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + + const found = await getOauthApp({ env, userId, slug: 'Google' }) + expect(found).toMatchObject({ + slug: 'google', + clientId: 'google-client-id-value', + }) + + const rotated = await rotateOauthAppClientCredentials({ + env, + userId, + slug: ' Google ', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + }) + expect(rotated).toMatchObject({ + slug: 'google', + clientId: 'google-client-id-rotated', + clientSecretSecretName: 'googleClientSecretRotated', + }) + + await expect( + deleteOauthAppIfUnused({ env, userId, slug: 'GOOGLE' }), + ).rejects.toThrow(/still has 1 connection/) +}) diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 25672670d0..2ec6ebcc93 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -298,7 +298,7 @@ export async function getOauthApp(input: { userId: string slug: string }): Promise { - const slug = input.slug.trim() + const slug = canonicalizeOauthAppSlug(input.slug) if (!slug) return null return getOauthAppBySlug({ db: input.env.APP_DB, @@ -314,7 +314,7 @@ export async function rotateOauthAppClientCredentials(input: { clientId: string clientSecretSecretName?: string | null }): Promise { - const slug = input.slug.trim() + const slug = canonicalizeOauthAppSlug(input.slug) const clientId = input.clientId.trim() if (!slug) throw new Error('OAuth app slug is required.') if (!clientId) throw new Error('Client id is required.') @@ -370,7 +370,7 @@ export async function deleteOauthAppIfUnused(input: { userId: string slug: string }): Promise { - const slug = input.slug.trim() + const slug = canonicalizeOauthAppSlug(input.slug) if (!slug) return false const existing = await getOauthAppBySlug({ db: input.env.APP_DB, @@ -395,6 +395,10 @@ export async function deleteOauthAppIfUnused(input: { }) } +function canonicalizeOauthAppSlug(slug: string) { + return canonicalIntegrationName(slug.trim()) +} + function buildOauthAppRow(input: { userId: string slug: string diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index ca2b72ae83..dc0aa2feda 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -430,7 +430,7 @@ }, { "filename": "0101-user-oauth-apps-and-integrations.sql", - "sha256": "d26af3d1f535d8329dc711ad367d5dfa5bf484046fd48c1f8df83f4093668a5d" + "sha256": "72c0ab34fab8cd2b204ac6212e8efbba154a4acc6b3f6fe4c79a2415237944fc" } ] } From 9a3759c737070aa413eada13bd531f63eee01b22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 06:43:29 +0000 Subject: [PATCH 11/15] fix(integrations): persist the setup client id and share one app-resolution path The setup step held the entered client id only in session storage, so abandoning the flow before token exchange lost it and a later reconnect showed an empty field. Re-entering a client id means a trip back to the provider's dashboard, so setup now persists the app row up front. A connectionless app is a valid intermediate state: the FK points from connection to app, not the reverse. Reusing a matched app no longer rewrites its provider. That field is derived from the incoming connection name, so saving an unrelated connection with an identical app tuple relabeled the app its siblings share. Reuse is now purely additive, and a connection moved off its old app takes any orphaned app row with it. Both write paths now go through one resolveOrCreateOauthApp, so the usePkce, token-exchange-style, client-secret, and slug-allocation rules cannot drift between setup and connect. The first draft of the setup fix reimplemented all of them in the app layer, which is how two of the reviewer findings on this branch happened in the first place. Also correct docs that named requiredHosts instead of required_hosts_json, implied every credential lives in secret_entries when client_id is inline, and described app matching as credentials plus endpoints rather than the full app tuple. Co-authored-by: Kent C. Dodds --- .../contributing/architecture/data-storage.md | 5 +- docs/guides/oauth.md | 8 +- .../client/routes/connect-oauth.node.test.ts | 87 +++ .../worker/client/routes/connect-oauth.tsx | 55 +- .../src/app/account-integrations-data.ts | 56 +- .../account-integrations.node.test.ts | 52 ++ .../app/handlers/account-secrets.node.test.ts | 164 ++++++ .../src/app/handlers/account-secrets.ts | 114 +++- .../src/integrations/service.node.test.ts | 517 +++++++++++++++++- packages/worker/src/integrations/service.ts | 374 +++++++++---- .../integration-save.node.test.ts | 7 +- 11 files changed, 1334 insertions(+), 105 deletions(-) diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md index 9897dde3b9..8dd44fcba5 100644 --- a/docs/contributing/architecture/data-storage.md +++ b/docs/contributing/architecture/data-storage.md @@ -290,8 +290,9 @@ The schema is defined by migrations in `packages/worker/migrations/`: - `user_integrations` (`0101-user-oauth-apps-and-integrations.sql`): per-user OAuth connections keyed by `(user_id, name)`, with composite FK `(user_id, app_slug) → user_oauth_apps(user_id, slug)` (`ON DELETE RESTRICT`). - Holds scopes, `requiredHosts`, and access/refresh token secret names. - Credential values stay in `secret_entries`. + Holds `scopes_json`, `required_hosts_json`, and access/refresh token secret + names. Secret credential values stay in `secret_entries`; the non-secret + `client_id` is stored inline on `user_oauth_apps`. App access pattern: diff --git a/docs/guides/oauth.md b/docs/guides/oauth.md index 14e69e5d88..3c01033ea1 100644 --- a/docs/guides/oauth.md +++ b/docs/guides/oauth.md @@ -116,9 +116,11 @@ Integration identity is the canonical provider key: names are normalized to lowercase kebab (letters, numbers, `.`, `_`, `-`) on every save and lookup, so `GitHub`, `github`, and `Git Hub` all resolve to the same `github` connection. Each connection is a D1 row in `user_integrations` keyed by `(user_id, name)`. -Connections that share the same OAuth client credentials and provider endpoints -share one `user_oauth_apps` row; rotating that app's client credentials updates -every sibling connection. +Connections share one `user_oauth_apps` row only when their entire app-level +configuration matches: client credentials, provider endpoints, flow and PKCE, +token exchange style, scope separator, and extra authorize params. Anything that +differs gets its own app. Rotating an app's client credentials updates every +connection sharing it. Prefer integration names like `-` when multiple accounts may exist: `google` for a default account, `google-business` for a business account, diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index 357f019b08..6408d4c1bf 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -476,6 +476,93 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho }) }) +test('abandoned setup still prefills client id from a connectionless app on a fresh session', () => { + // This is the regression: after setup we persist the app (not only session + // storage). A later visit with no session snapshot must still see the + // client id via the integrations.json?name= lookup payload. + const storedFromAppOnlyLookup = parseStoredIntegrationConfig( + { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + flow: 'pkce', + clientId: 'spotify-client-from-setup', + clientSecretSecretName: null, + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: [], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: [], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + }, + null, + ) + expect(storedFromAppOnlyLookup?.clientId).toBe('spotify-client-from-setup') + + const withPersistedApp = mergeConnectOauthConfig({ + queryConfig: { + provider: 'spotify', + providerKey: 'spotify', + authorizeHost: 'accounts.spotify.com', + authorizeUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + scopes: ['user-read-playback-state'], + flow: 'pkce', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: ' ', + extraAuthorizeParams: {}, + providerSetupInstructions: null, + dashboardUrl: null, + allowedHosts: ['accounts.spotify.com'], + }, + storedIntegration: storedFromAppOnlyLookup, + }) + expect(withPersistedApp?.clientId).toBe('spotify-client-from-setup') + expect( + summarizeStoredSetupState({ + flow: 'pkce', + clientId: withPersistedApp?.clientId ?? '', + hasStoredClientSecret: false, + }).isReady, + ).toBe(true) + + // Without the setup-time app persist, a fresh session has no stored + // integration and the client id field is empty — the bug this test guards. + const withoutPersistedApp = mergeConnectOauthConfig({ + queryConfig: { + provider: 'spotify', + providerKey: 'spotify', + authorizeHost: 'accounts.spotify.com', + authorizeUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + scopes: ['user-read-playback-state'], + flow: 'pkce', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: ' ', + extraAuthorizeParams: {}, + providerSetupInstructions: null, + dashboardUrl: null, + allowedHosts: ['accounts.spotify.com'], + }, + storedIntegration: null, + }) + expect(withoutPersistedApp?.clientId).toBe('') + expect( + summarizeStoredSetupState({ + flow: 'pkce', + clientId: withoutPersistedApp?.clientId ?? '', + hasStoredClientSecret: false, + }).isReady, + ).toBe(false) +}) + test('connect OAuth keeps slack comma scope separators and extra authorize params', () => { const slackConfig = mergeConnectOauthConfig({ queryConfig: { diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 34fc922c9c..7c5a6cecf3 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -127,6 +127,10 @@ type OAuthExchangeResult = type SaveSecretResult = { ok: true } | { ok: false; error: string } +type SaveOauthAppResult = + | { ok: true; clientId: string } + | { ok: false; error: string } + type ConnectOauthHostApprovalLink = { secretName: string host: string @@ -604,6 +608,49 @@ export function ConnectOauthRoute(handle: Handle) { return { ok: true, data, status: response.status } } + const saveOauthApp = async ( + nextConfig: ConnectOauthConfig, + ): Promise => { + const response = await fetch('/account/secrets.json', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action: 'save_oauth_app', + provider: nextConfig.provider, + authorizeUrl: nextConfig.authorizeUrl, + tokenUrl: nextConfig.tokenUrl, + apiBaseUrl: nextConfig.apiBaseUrl, + flow: nextConfig.flow, + usePkce: nextConfig.usePkce, + tokenExchangeStyle: nextConfig.tokenExchangeStyle, + clientId: nextConfig.clientId, + clientSecretSecretName: nextConfig.clientSecretSecretName, + scopeSeparator: nextConfig.scopeSeparator, + extraAuthorizeParams: nextConfig.extraAuthorizeParams, + }), + }) + if (redirectToLoginOn401(response)) { + return { ok: false, error: 'Session expired.' } + } + const payload = await response.json().catch(() => null) + if (!response.ok || payload?.ok !== true) { + return { + ok: false, + error: payload?.error || 'Unable to save OAuth app configuration.', + } + } + const savedClientId = + typeof payload.app?.clientId === 'string' ? payload.app.clientId : null + if (!savedClientId) { + return { ok: false, error: 'Unable to save OAuth app configuration.' } + } + return { ok: true, clientId: savedClientId } + } + const handleSetupSubmit = async (event: Event) => { event.preventDefault() if (!config || submitting) return @@ -638,7 +685,13 @@ export function ConnectOauthRoute(handle: Handle) { revealStoredClientSecretField = false clientSecretInput = '' } - config = { ...config, clientId } + const nextConfig = { ...config, clientId } + const appResult = await saveOauthApp(nextConfig) + if (!appResult.ok) { + setStatus(appResult.error, 'error') + return + } + config = { ...nextConfig, clientId: appResult.clientId } persistConfig(config) hasStoredClientId = true setStatus('Saved OAuth client configuration.', 'info') diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index 83ed8293b3..2e370ba836 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -1,11 +1,14 @@ import { type AccountIntegrationsLoaderData } from '#app/loader-data.ts' import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { canonicalIntegrationName } from '#mcp/capabilities/integrations/integration-shared.ts' import { getJoinedIntegration, + getOauthApp, listJoinedIntegrations, toIntegrationConfig, type IntegrationConfig, } from '#worker/integrations/service.ts' +import { type UserOauthApp } from '#worker/integrations/types.ts' type AuthenticatedUser = NonNullable< Awaited> @@ -36,6 +39,46 @@ function toAccountIntegrationRecord(input: { } } +/** + * Convert a connectionless OAuth app (unfinished setup) into the connect-flow + * payload shape. Secret names are defaults so reconnect can proceed; the + * connection row is still created only after token exchange. + */ +function toAppOnlyIntegrationRecord( + app: UserOauthApp, +): AccountIntegrationRecord { + const providerKey = canonicalIntegrationName(app.slug) || app.slug + return { + name: providerKey, + appSlug: app.slug, + provider: app.provider, + appLabel: app.label, + accountLabel: null, + tokenUrl: app.tokenUrl, + apiBaseUrl: app.apiBaseUrl, + flow: app.flow, + ...(typeof app.usePkce === 'boolean' ? { usePkce: app.usePkce } : {}), + clientId: app.clientId, + clientSecretSecretName: app.clientSecretSecretName, + accessTokenSecretName: `${providerKey}AccessToken`, + refreshTokenSecretName: `${providerKey}RefreshToken`, + requiredHosts: [], + ...(app.tokenExchangeStyle + ? { tokenExchangeStyle: app.tokenExchangeStyle } + : {}), + authorization: app.authorizeUrl + ? { + authorizeUrl: app.authorizeUrl, + scopes: [], + scopeSeparator: app.scopeSeparator, + extraAuthorizeParams: app.extraAuthorizeParams, + } + : null, + createdAt: app.createdAt, + updatedAt: app.updatedAt, + } +} + export async function loadAccountIntegrationsData( env: Env, user: AuthenticatedUser, @@ -70,6 +113,15 @@ export async function loadAccountIntegrationByName( userId: user.mcpUser.userId, name, }) - if (!joined) return null - return toAccountIntegrationRecord(joined) + if (joined) return toAccountIntegrationRecord(joined) + + // Unfinished setup persists an app with no connection. Surface it for the + // connect flow so a later session still sees the entered client id. + const app = await getOauthApp({ + env, + userId: user.mcpUser.userId, + slug: name, + }) + if (!app) return null + return toAppOnlyIntegrationRecord(app) } diff --git a/packages/worker/src/app/handlers/account-integrations.node.test.ts b/packages/worker/src/app/handlers/account-integrations.node.test.ts index 06083ae520..db86df37bc 100644 --- a/packages/worker/src/app/handlers/account-integrations.node.test.ts +++ b/packages/worker/src/app/handlers/account-integrations.node.test.ts @@ -162,6 +162,7 @@ const mockModule = vi.hoisted(() => ({ updatedAt: '1970-01-01T00:00:00.001Z', }, })), + getOauthApp: vi.fn(async () => null), })) vi.mock('#app/authenticated-user.ts', () => ({ @@ -190,6 +191,7 @@ vi.mock('#worker/integrations/service.ts', async (importOriginal) => { mockModule.listJoinedIntegrations(...args), getJoinedIntegration: (...args: Array) => mockModule.getJoinedIntegration(...args), + getOauthApp: (...args: Array) => mockModule.getOauthApp(...args), } }) @@ -353,6 +355,7 @@ test('integrations API returns one connection by name for the connect OAuth flow test('integrations API returns null when a named connection is missing', async () => { mockModule.getJoinedIntegration.mockResolvedValueOnce(null) + mockModule.getOauthApp.mockResolvedValueOnce(null) const handler = createAccountIntegrationsApiHandler(createEnv()) const response = await handler.handler({ request: new Request( @@ -366,3 +369,52 @@ test('integrations API returns null when a named connection is missing', async ( integration: null, }) }) + +test('integrations API falls back to a connectionless OAuth app so abandoned setup still returns the client id', async () => { + mockModule.getJoinedIntegration.mockResolvedValueOnce(null) + mockModule.getOauthApp.mockResolvedValueOnce({ + userId: 'stable-user-1', + slug: 'spotify', + provider: 'spotify', + label: null, + clientId: 'spotify-client-from-setup', + clientSecretSecretName: null, + tokenUrl: 'https://accounts.spotify.com/api/token', + authorizeUrl: 'https://accounts.spotify.com/authorize', + apiBaseUrl: null, + flow: 'pkce' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt, + updatedAt, + }) + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request( + 'https://example.com/account/integrations.json?name=spotify', + ), + params: {}, + } as never) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + ok: true, + integration: { + name: 'spotify', + appSlug: 'spotify', + clientId: 'spotify-client-from-setup', + tokenUrl: 'https://accounts.spotify.com/api/token', + flow: 'pkce', + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: [], + }, + }, + }) + expect(mockModule.getOauthApp).toHaveBeenCalledWith({ + env: expect.any(Object), + userId: 'stable-user-1', + slug: 'spotify', + }) +}) diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index cb0be4c07c..5ca1358759 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -45,6 +45,40 @@ const mockModule = vi.hoisted(() => ({ ...input.config, name: String(input.config.name).toLowerCase(), })), + upsertOauthAppWithoutConnection: vi.fn( + async (input: { + config: { + name: string + clientId: string + tokenUrl: string + flow: 'pkce' | 'confidential' + clientSecretSecretName?: string | null + apiBaseUrl?: string | null + usePkce?: boolean | null + tokenExchangeStyle?: string | null + } + }) => ({ + userId: 'stable-user-1', + slug: String(input.config.name).toLowerCase().replace(/\s+/g, '-'), + provider: String(input.config.name) + .toLowerCase() + .replace(/\s+/g, '-') + .split('-')[0], + label: null, + clientId: input.config.clientId, + clientSecretSecretName: input.config.clientSecretSecretName ?? null, + tokenUrl: input.config.tokenUrl, + authorizeUrl: null, + apiBaseUrl: input.config.apiBaseUrl ?? null, + flow: input.config.flow, + usePkce: input.config.usePkce ?? null, + tokenExchangeStyle: input.config.tokenExchangeStyle ?? null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }), + ), searchCommunityListings: vi.fn(async () => []), })) @@ -118,6 +152,8 @@ vi.mock('#mcp/values/service.ts', () => ({ vi.mock('#worker/integrations/service.ts', () => ({ upsertIntegration: (...args: Array) => mockModule.upsertIntegration(...args), + upsertOauthAppWithoutConnection: (...args: Array) => + mockModule.upsertOauthAppWithoutConnection(...args), })) vi.mock('#worker/package-registry/repo.ts', () => ({ @@ -134,6 +170,134 @@ function createEnv() { } as Env } +test('save_oauth_app persists the app (client id + endpoints) before authorize redirect', async () => { + mockModule.upsertOauthAppWithoutConnection.mockClear() + const handler = createAccountSecretsApiHandler(createEnv()) + + const response = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save_oauth_app', + provider: 'GitHub', + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'pkce', + usePkce: true, + clientId: 'github-client-id-value', + scopeSeparator: ' ', + extraAuthorizeParams: { prompt: 'consent' }, + }), + }), + params: {}, + } as never) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + ok: true, + app: { + slug: 'github', + clientId: 'github-client-id-value', + tokenUrl: 'https://github.com/login/oauth/access_token', + flow: 'pkce', + }, + }) + expect(mockModule.upsertOauthAppWithoutConnection).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'stable-user-1', + config: expect.objectContaining({ + name: 'GitHub', + clientId: 'github-client-id-value', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'pkce', + usePkce: true, + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: [], + scopeSeparator: ' ', + extraAuthorizeParams: { prompt: 'consent' }, + }, + }), + }), + ) + expect(mockModule.upsertIntegration).not.toHaveBeenCalled() + expect(mockModule.saveSecret).not.toHaveBeenCalled() +}) + +test('save_oauth_app reuses an existing app when setting up a second account with the same client credentials', async () => { + mockModule.upsertOauthAppWithoutConnection.mockClear() + mockModule.upsertOauthAppWithoutConnection.mockImplementation( + async (input: { config: { name: string; clientId: string } }) => ({ + userId: 'stable-user-1', + slug: 'google', + provider: 'google', + label: null, + clientId: input.config.clientId, + clientSecretSecretName: null, + tokenUrl: 'https://oauth2.googleapis.com/token', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: {}, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }), + ) + const handler = createAccountSecretsApiHandler(createEnv()) + + const first = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save_oauth_app', + provider: 'google', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + }), + }), + params: {}, + } as never) + expect(first.status).toBe(200) + await expect(first.json()).resolves.toMatchObject({ + ok: true, + app: { slug: 'google', clientId: 'shared-google-client' }, + }) + + const second = await handler.handler({ + request: new Request('https://example.com/account/secrets.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save_oauth_app', + provider: 'google-calendar', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + }), + }), + params: {}, + } as never) + expect(second.status).toBe(200) + await expect(second.json()).resolves.toMatchObject({ + ok: true, + app: { slug: 'google', clientId: 'shared-google-client' }, + }) + expect(mockModule.upsertOauthAppWithoutConnection).toHaveBeenCalledTimes(2) + expect(mockModule.upsertIntegration).not.toHaveBeenCalled() +}) + test('connect oauth saves tokens via the secret store and persists app+connection through the integrations service', async () => { mockModule.upsertIntegration.mockClear() mockModule.saveSecret.mockClear() diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index ad4c313ba1..4a29f62544 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -40,7 +40,10 @@ import { normalizeIntegrationConfig, integrationConfigSchema, } from '#mcp/capabilities/integrations/integration-shared.ts' -import { upsertIntegration } from '#worker/integrations/service.ts' +import { + upsertIntegration, + upsertOauthAppWithoutConnection, +} from '#worker/integrations/service.ts' import { requireAuthenticatedPageUser } from '#app/page-auth.ts' import { buildOAuthTokenExchangeFailurePayload, @@ -159,6 +162,13 @@ export function createAccountSecretsApiHandler(env: Env) { body, }) } + if (action === 'save_oauth_app') { + return handleSaveOauthAppAction({ + env, + user, + body, + }) + } if (action === 'connect_oauth') { return handleConnectOauthAction({ request, @@ -229,6 +239,108 @@ async function handleValueSetAction(input: { return jsonResponse({ ok: true, value: { value: saved.value } }) } +async function handleSaveOauthAppAction(input: { + env: Env + user: NonNullable>> + body: object +}) { + const provider = readString(input.body, 'provider') + const tokenUrl = readOptionalString(input.body, 'tokenUrl') + const apiBaseUrl = readOptionalString(input.body, 'apiBaseUrl') + const authorizeUrl = readOptionalString(input.body, 'authorizeUrl') + const flow = readOptionalString(input.body, 'flow') + const usePkce = readOptionalBoolean(input.body, 'usePkce') + const clientId = readOptionalString(input.body, 'clientId') + const clientSecretSecretName = readOptionalString( + input.body, + 'clientSecretSecretName', + ) + const scopeSeparator = readRawOptionalString(input.body, 'scopeSeparator') + const extraAuthorizeParams = readStringRecord( + input.body, + 'extraAuthorizeParams', + ) + + if (!provider) { + return jsonResponse({ ok: false, error: 'Provider is required.' }, 400) + } + if (!tokenUrl) { + return jsonResponse({ ok: false, error: 'Token URL is required.' }, 400) + } + if (!clientId) { + return jsonResponse({ ok: false, error: 'Client ID is required.' }, 400) + } + if (flow && flow !== 'pkce' && flow !== 'confidential') { + return jsonResponse({ ok: false, error: 'Invalid OAuth flow.' }, 400) + } + if (authorizeUrl) { + const authorizeHost = safeParseHost(authorizeUrl) + if (!authorizeHost) { + return jsonResponse( + { ok: false, error: 'Authorize URL is invalid.' }, + 400, + ) + } + } + + try { + const app = await upsertOauthAppWithoutConnection({ + env: input.env, + userId: input.user.mcpUser.userId, + config: { + name: provider, + tokenUrl, + apiBaseUrl, + flow: flow === 'confidential' ? 'confidential' : 'pkce', + ...(usePkce == null ? {} : { usePkce }), + clientId, + clientSecretSecretName, + tokenExchangeStyle: resolveTokenExchangeStyle({ + tokenUrl, + tokenExchangeStyle: readOptionalString( + input.body, + 'tokenExchangeStyle', + ), + }), + authorization: authorizeUrl + ? { + authorizeUrl, + scopes: [], + scopeSeparator, + extraAuthorizeParams, + } + : null, + }, + }) + return jsonResponse({ + ok: true, + app: { + slug: app.slug, + provider: app.provider, + clientId: app.clientId, + clientSecretSecretName: app.clientSecretSecretName, + tokenUrl: app.tokenUrl, + authorizeUrl: app.authorizeUrl, + apiBaseUrl: app.apiBaseUrl, + flow: app.flow, + usePkce: app.usePkce, + tokenExchangeStyle: app.tokenExchangeStyle, + }, + }) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to save OAuth app configuration.', + }, + 400, + ) + } +} + async function handleConnectOauthAction(input: { request: Request env: Env diff --git a/packages/worker/src/integrations/service.node.test.ts b/packages/worker/src/integrations/service.node.test.ts index ca9800b6cc..2b3d239035 100644 --- a/packages/worker/src/integrations/service.node.test.ts +++ b/packages/worker/src/integrations/service.node.test.ts @@ -11,6 +11,7 @@ import { listJoinedIntegrations, rotateOauthAppClientCredentials, upsertIntegration, + upsertOauthAppWithoutConnection, } from './service.ts' const migrationsDirectory = new URL('../../migrations/', import.meta.url) @@ -47,7 +48,7 @@ const baseGoogleConfig = { apiBaseUrl: 'https://www.googleapis.com', flow: 'pkce' as const, clientId: 'google-client-id-value', - clientSecretSecretName: 'googleClientSecret', + clientSecretSecretName: null as string | null, accessTokenSecretName: 'googleAccessToken', refreshTokenSecretName: 'googleRefreshToken', requiredHosts: ['www.googleapis.com', 'accounts.google.com'], @@ -341,3 +342,517 @@ test('getOauthApp and rotateOauthAppClientCredentials canonicalize mixed-case sl deleteOauthAppIfUnused({ env, userId, slug: 'GOOGLE' }), ).rejects.toThrow(/still has 1 connection/) }) + +test('reusing a matched app does not rewrite provider or other app identity fields', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-provider-preserve' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-calendar', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + }, + }) + + const before = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps + WHERE user_id = ? AND slug = 'google'`, + ) + .get(userId) as { + slug: string + provider: string + label: string | null + client_id: string + token_url: string + created_at: string + updated_at: string + } + expect(before.provider).toBe('google') + + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'acme-thing', + accessTokenSecretName: 'acmeAccessToken', + refreshTokenSecretName: 'acmeRefreshToken', + authorization: { + ...baseGoogleConfig.authorization, + scopes: ['acme.scope'], + }, + requiredHosts: ['www.googleapis.com'], + }, + }) + + const after = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps + WHERE user_id = ? AND slug = 'google'`, + ) + .get(userId) as typeof before + + expect(after).toEqual(before) + expect(after.provider).toBe('google') + + const apps = await listOauthApps({ env, userId }) + expect(apps).toHaveLength(1) + expect(apps[0]?.connectionCount).toBe(3) +}) + +test('moving the last connection off an app deletes the orphan app row', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-orphan' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'solo-app', + clientId: 'solo-client-id', + clientSecretSecretName: 'soloClientSecret', + accessTokenSecretName: 'soloAccessToken', + refreshTokenSecretName: 'soloRefreshToken', + }, + }) + expect( + sqlite + .prepare( + `SELECT slug FROM user_oauth_apps WHERE user_id = ? ORDER BY slug`, + ) + .all(userId), + ).toEqual([{ slug: 'google' }, { slug: 'solo-app' }]) + + // Rematch solo-app onto the google app tuple → previous solo app is orphaned. + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'solo-app', + accessTokenSecretName: 'soloAccessToken', + refreshTokenSecretName: 'soloRefreshToken', + }, + }) + + expect( + sqlite + .prepare( + `SELECT slug FROM user_oauth_apps WHERE user_id = ? ORDER BY slug`, + ) + .all(userId), + ).toEqual([{ slug: 'google' }]) + expect( + sqlite + .prepare( + `SELECT name, app_slug FROM user_integrations + WHERE user_id = ? ORDER BY name`, + ) + .all(userId), + ).toEqual([ + { name: 'google', app_slug: 'google' }, + { name: 'solo-app', app_slug: 'google' }, + ]) +}) + +test('moving a non-last connection off an app leaves siblings intact', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-sibling-keep' + + for (const name of [ + 'google', + 'google-calendar', + 'google-mail', + 'google-drive', + ] as const) { + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name, + accessTokenSecretName: `${name}AccessToken`, + refreshTokenSecretName: `${name}RefreshToken`, + authorization: { + ...baseGoogleConfig.authorization, + scopes: name === 'google' ? ['openid', 'email'] : [`${name}.scope`], + }, + }, + }) + } + + expect( + ( + sqlite + .prepare( + `SELECT count(*) AS count FROM user_integrations + WHERE user_id = ? AND app_slug = 'google'`, + ) + .get(userId) as { count: number } + ).count, + ).toBe(4) + + // Move one connection to a different app tuple; the google app keeps 3. + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-drive', + tokenUrl: 'https://oauth2.googleapis.com/token/other', + accessTokenSecretName: 'googleDriveAccessToken', + refreshTokenSecretName: 'googleDriveRefreshToken', + }, + }) + + const googleApp = sqlite + .prepare( + `SELECT slug, provider FROM user_oauth_apps + WHERE user_id = ? AND slug = 'google'`, + ) + .get(userId) as { slug: string; provider: string } + expect(googleApp).toEqual({ slug: 'google', provider: 'google' }) + expect( + ( + sqlite + .prepare( + `SELECT count(*) AS count FROM user_integrations + WHERE user_id = ? AND app_slug = 'google'`, + ) + .get(userId) as { count: number } + ).count, + ).toBe(3) + expect( + sqlite + .prepare( + `SELECT name, app_slug FROM user_integrations + WHERE user_id = ? AND name = 'google-drive'`, + ) + .get(userId), + ).toEqual({ name: 'google-drive', app_slug: 'google-drive' }) +}) + +test('re-saving one of four shared connections with new scopes keeps the shared app', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-four-shared' + + const names = [ + 'google', + 'google-calendar', + 'google-mail', + 'google-drive', + ] as const + for (const name of names) { + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name, + accessTokenSecretName: `${name}AccessToken`, + refreshTokenSecretName: `${name}RefreshToken`, + authorization: { + ...baseGoogleConfig.authorization, + scopes: [`${name}.initial`], + }, + requiredHosts: ['www.googleapis.com', 'accounts.google.com'], + }, + }) + } + + const beforeApp = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps WHERE user_id = ?`, + ) + .get(userId) + + await upsertIntegration({ + env, + userId, + config: { + ...baseGoogleConfig, + name: 'google-mail', + accessTokenSecretName: 'googleMailAccessToken', + refreshTokenSecretName: 'googleMailRefreshToken', + authorization: { + ...baseGoogleConfig.authorization, + scopes: ['gmail.modify', 'gmail.readonly'], + }, + requiredHosts: ['gmail.googleapis.com'], + }, + }) + + const afterApp = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps WHERE user_id = ?`, + ) + .get(userId) + expect(afterApp).toEqual(beforeApp) + + const connections = sqlite + .prepare( + `SELECT name, app_slug, scopes_json, required_hosts_json + FROM user_integrations WHERE user_id = ? ORDER BY name`, + ) + .all(userId) as Array<{ + name: string + app_slug: string + scopes_json: string + required_hosts_json: string + }> + expect(connections).toHaveLength(4) + expect(connections.every((row) => row.app_slug === 'google')).toBe(true) + expect(connections.find((row) => row.name === 'google-mail')).toMatchObject({ + scopes_json: JSON.stringify(['gmail.modify', 'gmail.readonly']), + required_hosts_json: JSON.stringify(['gmail.googleapis.com']), + }) +}) + +test('upsertOauthAppWithoutConnection persists an app with zero connections; later connect attaches to it', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-setup-then-connect' + + const app = await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + flow: 'pkce', + usePkce: true, + clientId: 'spotify-client-from-setup', + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: [], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + expect(app).toMatchObject({ + slug: 'spotify', + clientId: 'spotify-client-from-setup', + tokenUrl: 'https://accounts.spotify.com/api/token', + flow: 'pkce', + }) + + const appsBeforeConnect = await listOauthApps({ env, userId }) + expect(appsBeforeConnect).toHaveLength(1) + expect(appsBeforeConnect[0]).toMatchObject({ + slug: 'spotify', + connectionCount: 0, + clientId: 'spotify-client-from-setup', + }) + expect(await listIntegrations({ env, userId })).toEqual([]) + expect(await listJoinedIntegrations({ env, userId })).toEqual([]) + expect( + ( + sqlite + .prepare( + `SELECT count(*) AS count FROM user_integrations WHERE user_id = ?`, + ) + .get(userId) as { count: number } + ).count, + ).toBe(0) + + await upsertIntegration({ + env, + userId, + config: { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: null, + flow: 'pkce', + clientId: 'spotify-client-from-setup', + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + + const appsAfterConnect = await listOauthApps({ env, userId }) + expect(appsAfterConnect).toHaveLength(1) + expect(appsAfterConnect[0]).toMatchObject({ + slug: 'spotify', + connectionCount: 1, + clientId: 'spotify-client-from-setup', + }) + const listed = await listIntegrations({ env, userId }) + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ + name: 'spotify', + clientId: 'spotify-client-from-setup', + }) +}) + +test('upsertOauthAppWithoutConnection reuses a connectionless preferred slug when the client id changes', async () => { + const { env } = createEnv() + const userId = 'user-setup-orphan-reuse' + + await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'notion', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + flow: 'confidential', + clientId: 'notion-client-old', + clientSecretSecretName: 'notionClientSecret', + authorization: { + authorizeUrl: 'https://api.notion.com/v1/oauth/authorize', + }, + }, + }) + + const updated = await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'notion', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + flow: 'confidential', + clientId: 'notion-client-new', + clientSecretSecretName: 'notionClientSecret', + authorization: { + authorizeUrl: 'https://api.notion.com/v1/oauth/authorize', + }, + }, + }) + + expect(updated).toMatchObject({ + slug: 'notion', + clientId: 'notion-client-new', + }) + const apps = await listOauthApps({ env, userId }) + expect(apps).toHaveLength(1) + expect(apps[0]).toMatchObject({ + slug: 'notion', + connectionCount: 0, + clientId: 'notion-client-new', + }) + expect(apps.map((entry) => entry.slug)).not.toContain('notion-2') + expect(await listIntegrations({ env, userId })).toEqual([]) + expect(await listJoinedIntegrations({ env, userId })).toEqual([]) +}) + +test('upsertOauthAppWithoutConnection matching a connected app does not rewrite provider or identity', async () => { + const { env, sqlite } = createEnv() + const userId = 'user-setup-preserve' + + await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [], + extraAuthorizeParams: { access_type: 'offline' }, + }, + }, + }) + + await upsertIntegration({ + env, + userId, + config: { + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, + }, + }) + + const before = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps + WHERE user_id = ? AND slug = 'google'`, + ) + .get(userId) as { + slug: string + provider: string + label: string | null + client_id: string + token_url: string + created_at: string + updated_at: string + } + expect(before.provider).toBe('google') + + const secondSetup = await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'google-calendar', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [], + extraAuthorizeParams: { access_type: 'offline' }, + }, + }, + }) + + expect(secondSetup.slug).toBe('google') + const after = sqlite + .prepare( + `SELECT slug, provider, label, client_id, token_url, created_at, updated_at + FROM user_oauth_apps + WHERE user_id = ? AND slug = 'google'`, + ) + .get(userId) as typeof before + expect(after).toEqual(before) + + const apps = await listOauthApps({ env, userId }) + expect(apps).toHaveLength(1) + expect(apps[0]).toMatchObject({ + slug: 'google', + connectionCount: 1, + clientId: 'shared-google-client', + }) +}) diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 2ec6ebcc93..2ed3c63524 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -1,6 +1,7 @@ import { canonicalIntegrationName, integrationConfigSchema, + normalizeIntegrationAuthorization, normalizeIntegrationConfig, type IntegrationConfig, } from '#mcp/capabilities/integrations/integration-shared.ts' @@ -28,6 +29,39 @@ import { export type { IntegrationConfig } +/** + * App-level OAuth config (no connection / token secret fields). Used by + * connect-flow setup to persist a client id before token exchange. + */ +export type OauthAppConfigInput = { + name: string + tokenUrl: string + apiBaseUrl?: string | null + flow: IntegrationConfig['flow'] + usePkce?: boolean | null + clientId: string + clientSecretSecretName?: string | null + tokenExchangeStyle?: IntegrationConfig['tokenExchangeStyle'] + authorization?: { + authorizeUrl: string + scopes?: Array + scopeSeparator?: string | null + extraAuthorizeParams?: Record + } | null +} + +type OauthAppWriteConfig = { + name: string + tokenUrl: string + apiBaseUrl: string | null + flow: IntegrationConfig['flow'] + usePkce?: boolean + clientId: string + clientSecretSecretName: string | null + tokenExchangeStyle?: NonNullable + authorization?: NonNullable +} + export function toIntegrationConfig( app: UserOauthApp, connection: UserIntegrationConnection, @@ -122,95 +156,13 @@ export async function upsertIntegration(input: { name: config.name, }) - const appRowFields = buildOauthAppRow({ - userId: input.userId, - slug: config.name, - config, - label: null, - createdAt: now, - updatedAt: now, - }) - const matchedApp = await findOauthAppByAppTuple({ + const { appSlug } = await resolveOrCreateOauthApp({ db: input.env.APP_DB, userId: input.userId, - clientId: appRowFields.client_id, - clientSecretSecretName: appRowFields.client_secret_secret_name, - tokenUrl: appRowFields.token_url, - authorizeUrl: appRowFields.authorize_url, - apiBaseUrl: appRowFields.api_base_url, - flow: appRowFields.flow, - usePkce: appRowFields.use_pkce, - tokenExchangeStyle: appRowFields.token_exchange_style, - scopeSeparator: appRowFields.scope_separator, - extraAuthorizeParamsJson: appRowFields.extra_authorize_params_json, + config: oauthAppWriteConfigFromIntegration(config), + existingConnectionApp: existing?.app ?? null, }) - let appSlug: string - if (matchedApp) { - appSlug = matchedApp.slug - await upsertOauthApp({ - db: input.env.APP_DB, - row: { - ...appRowFields, - slug: matchedApp.slug, - label: matchedApp.label, - created_at: matchedApp.createdAt, - updated_at: now, - }, - }) - } else if (existing) { - const siblingCount = await countConnectionsForApp({ - db: input.env.APP_DB, - userId: input.userId, - appSlug: existing.app.slug, - }) - if (siblingCount <= 1) { - appSlug = existing.app.slug - await upsertOauthApp({ - db: input.env.APP_DB, - row: { - ...appRowFields, - slug: existing.app.slug, - label: existing.app.label, - created_at: existing.app.createdAt, - updated_at: now, - }, - }) - } else { - appSlug = await allocateAppSlug({ - db: input.env.APP_DB, - userId: input.userId, - preferredSlug: config.name, - }) - await upsertOauthApp({ - db: input.env.APP_DB, - row: { - ...appRowFields, - slug: appSlug, - label: null, - created_at: now, - updated_at: now, - }, - }) - } - } else { - appSlug = await allocateAppSlug({ - db: input.env.APP_DB, - userId: input.userId, - preferredSlug: config.name, - }) - await upsertOauthApp({ - db: input.env.APP_DB, - row: { - ...appRowFields, - slug: appSlug, - label: null, - created_at: now, - updated_at: now, - }, - }) - } - await upsertIntegrationConnection({ db: input.env.APP_DB, row: { @@ -238,6 +190,14 @@ export async function upsertIntegration(input: { }, }) + if (existing && existing.app.slug !== appSlug) { + await deleteOauthAppIfNoConnections({ + db: input.env.APP_DB, + userId: input.userId, + appSlug: existing.app.slug, + }) + } + const saved = await getIntegration({ env: input.env, userId: input.userId, @@ -249,6 +209,38 @@ export async function upsertIntegration(input: { return saved } +/** + * Persist an OAuth app row (client id + endpoints) without creating a + * connection. Used by the connect-oauth setup step so the client id survives + * abandoning the flow before token exchange. + * + * Shares resolve/create rules with `upsertIntegration`'s app branch, including + * full-tuple reuse and reclaiming a connectionless preferred slug when the + * user re-runs setup with an edited client id. + */ +export async function upsertOauthAppWithoutConnection(input: { + env: Pick + userId: string + config: OauthAppConfigInput +}): Promise { + const config = normalizeOauthAppConfig(input.config) + const { appSlug } = await resolveOrCreateOauthApp({ + db: input.env.APP_DB, + userId: input.userId, + config, + existingConnectionApp: null, + }) + const saved = await getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug: appSlug, + }) + if (!saved) { + throw new Error(`Failed to persist OAuth app "${appSlug}".`) + } + return saved +} + export async function deleteIntegration(input: { env: Pick userId: string @@ -268,19 +260,31 @@ export async function deleteIntegration(input: { name, }) if (!deleted) return false - const remaining = await countConnectionsForApp({ + await deleteOauthAppIfNoConnections({ db: input.env.APP_DB, userId: input.userId, appSlug: existing.app.slug, }) + return true +} + +async function deleteOauthAppIfNoConnections(input: { + db: D1Database + userId: string + appSlug: string +}): Promise { + const remaining = await countConnectionsForApp({ + db: input.db, + userId: input.userId, + appSlug: input.appSlug, + }) if (remaining === 0) { await deleteOauthApp({ - db: input.env.APP_DB, + db: input.db, userId: input.userId, - slug: existing.app.slug, + slug: input.appSlug, }) } - return true } export async function listOauthApps(input: { @@ -399,23 +403,204 @@ function canonicalizeOauthAppSlug(slug: string) { return canonicalIntegrationName(slug.trim()) } +function oauthAppWriteConfigFromIntegration( + config: IntegrationConfig, +): OauthAppWriteConfig { + return { + name: config.name, + tokenUrl: config.tokenUrl, + apiBaseUrl: config.apiBaseUrl ?? null, + flow: config.flow, + ...(typeof config.usePkce === 'boolean' ? { usePkce: config.usePkce } : {}), + clientId: config.clientId, + clientSecretSecretName: config.clientSecretSecretName ?? null, + ...(config.tokenExchangeStyle + ? { tokenExchangeStyle: config.tokenExchangeStyle } + : {}), + ...(config.authorization ? { authorization: config.authorization } : {}), + } +} + +function normalizeOauthAppConfig( + value: OauthAppConfigInput, +): OauthAppWriteConfig { + const preferredSlug = canonicalIntegrationName(value.name) + if (!preferredSlug) { + throw new Error('Provider must contain letters or numbers.') + } + const clientId = value.clientId.trim() + if (!clientId) { + throw new Error('Client ID is required.') + } + const tokenUrl = value.tokenUrl.trim() + if (!tokenUrl) { + throw new Error('Token URL is required.') + } + const authorization = value.authorization + ? normalizeIntegrationAuthorization({ + authorizeUrl: value.authorization.authorizeUrl, + scopes: value.authorization.scopes ?? [], + scopeSeparator: value.authorization.scopeSeparator ?? null, + extraAuthorizeParams: value.authorization.extraAuthorizeParams ?? {}, + }) + : null + // Store usePkce only when it differs from the flow default so tuple + // matching stays aligned with normalizeIntegrationConfig / migration. + const usePkce = + typeof value.usePkce === 'boolean' && + value.usePkce !== (value.flow === 'pkce') + ? value.usePkce + : null + const tokenExchangeStyle = value.tokenExchangeStyle ?? null + return { + name: preferredSlug, + tokenUrl, + apiBaseUrl: value.apiBaseUrl?.trim() || null, + flow: value.flow, + ...(usePkce == null ? {} : { usePkce }), + clientId, + clientSecretSecretName: value.clientSecretSecretName?.trim() || null, + ...(tokenExchangeStyle ? { tokenExchangeStyle } : {}), + ...(authorization ? { authorization } : {}), + } +} + +/** + * Resolve or create the OAuth app row for this config. Shared by + * `upsertIntegration` (then attaches a connection) and + * `upsertOauthAppWithoutConnection` (setup with zero connections). + */ +async function resolveOrCreateOauthApp(input: { + db: D1Database + userId: string + config: OauthAppWriteConfig + existingConnectionApp: UserOauthApp | null +}): Promise<{ appSlug: string }> { + const now = new Date().toISOString() + const appTupleFields = buildOauthAppRow({ + userId: input.userId, + slug: input.config.name, + config: input.config, + label: null, + createdAt: now, + updatedAt: now, + }) + const matchedApp = await findOauthAppByAppTuple({ + db: input.db, + userId: input.userId, + clientId: appTupleFields.client_id, + clientSecretSecretName: appTupleFields.client_secret_secret_name, + tokenUrl: appTupleFields.token_url, + authorizeUrl: appTupleFields.authorize_url, + apiBaseUrl: appTupleFields.api_base_url, + flow: appTupleFields.flow, + usePkce: appTupleFields.use_pkce, + tokenExchangeStyle: appTupleFields.token_exchange_style, + scopeSeparator: appTupleFields.scope_separator, + extraAuthorizeParamsJson: appTupleFields.extra_authorize_params_json, + }) + + if (matchedApp) { + // Additive reuse: attach/return only. Do not rewrite slug-derived + // identity (provider), label, created_at, or updated_at on the app. + return { appSlug: matchedApp.slug } + } + + if (input.existingConnectionApp) { + const siblingCount = await countConnectionsForApp({ + db: input.db, + userId: input.userId, + appSlug: input.existingConnectionApp.slug, + }) + if (siblingCount <= 1) { + // Sole owner: update app-level fields in place, keeping slug identity. + await upsertOauthApp({ + db: input.db, + row: buildOauthAppRow({ + userId: input.userId, + slug: input.existingConnectionApp.slug, + config: input.config, + label: input.existingConnectionApp.label, + createdAt: input.existingConnectionApp.createdAt, + updatedAt: now, + }), + }) + return { appSlug: input.existingConnectionApp.slug } + } + } + + const preferred = await getOauthAppBySlug({ + db: input.db, + userId: input.userId, + slug: input.config.name, + }) + if (preferred) { + const connectionCount = await countConnectionsForApp({ + db: input.db, + userId: input.userId, + appSlug: preferred.slug, + }) + // An unfinished setup leaves an app with zero connections. Reuse that + // slug when the user re-runs setup (or connects) for the same provider + // key so we do not pile up orphan apps as they edit the client id. + if (connectionCount === 0) { + await upsertOauthApp({ + db: input.db, + row: buildOauthAppRow({ + userId: input.userId, + slug: preferred.slug, + config: input.config, + label: preferred.label, + createdAt: preferred.createdAt, + updatedAt: now, + }), + }) + return { appSlug: preferred.slug } + } + } + + const appSlug = await allocateAppSlug({ + db: input.db, + userId: input.userId, + preferredSlug: input.config.name, + }) + await upsertOauthApp({ + db: input.db, + row: buildOauthAppRow({ + userId: input.userId, + slug: appSlug, + config: input.config, + label: null, + createdAt: now, + updatedAt: now, + }), + }) + return { appSlug } +} + function buildOauthAppRow(input: { userId: string slug: string - config: IntegrationConfig + config: OauthAppWriteConfig label: string | null createdAt: string updatedAt: string }) { const provider = providerFromSlug(input.slug) const authorization = input.config.authorization ?? null + const tokenExchangeStyle = input.config.tokenExchangeStyle ?? null return { user_id: input.userId, slug: input.slug, provider, label: input.label, client_id: input.config.clientId, - client_secret_secret_name: input.config.clientSecretSecretName ?? null, + // Client secrets are only meaningful for confidential apps; pkce rows + // must store NULL so setup and connect tuple-match the same way. + client_secret_secret_name: + input.config.flow === 'confidential' + ? (input.config.clientSecretSecretName ?? null) + : null, token_url: input.config.tokenUrl, authorize_url: authorization?.authorizeUrl ?? null, api_base_url: input.config.apiBaseUrl ?? null, @@ -426,7 +611,12 @@ function buildOauthAppRow(input: { ? 1 : 0 : null, - token_exchange_style: input.config.tokenExchangeStyle ?? null, + // 'form' is the default exchange style; store NULL so migrated rows and + // connect-flow writes share one tuple shape. + token_exchange_style: + tokenExchangeStyle && tokenExchangeStyle !== 'form' + ? tokenExchangeStyle + : null, scope_separator: authorization?.scopeSeparator ?? null, extra_authorize_params_json: JSON.stringify( authorization?.extraAuthorizeParams ?? {}, diff --git a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts index aca419f68b..0b028d3458 100644 --- a/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts +++ b/packages/worker/src/mcp/capabilities/integrations/integration-save.node.test.ts @@ -64,7 +64,8 @@ const googleBase = { apiBaseUrl: 'https://www.googleapis.com', flow: 'pkce' as const, clientId: 'google-client-id-value', - clientSecretSecretName: 'googleClientSecret', + // pkce apps do not persist a client secret (confidential-only at write time). + clientSecretSecretName: null as string | null, accessTokenSecretName: 'googleAccessToken', refreshTokenSecretName: 'googleRefreshToken', requiredHosts: ['www.googleapis.com', 'accounts.google.com'], @@ -202,7 +203,7 @@ test('integration_save reuses an existing app when credentials match and preserv slug: 'google', connectionCount: 2, clientId: 'google-client-id-value', - clientSecretSecretName: 'googleClientSecret', + clientSecretSecretName: null, }) expect(apps.apps[0]?.connections.map((entry) => entry.name).sort()).toEqual([ 'google', @@ -220,7 +221,7 @@ test('integration_save reuses an existing app when credentials match and preserv name: 'google', apiBaseUrl: 'https://www.googleapis.com/v2', clientId: 'google-client-id-value', - clientSecretSecretName: 'googleClientSecret', + clientSecretSecretName: null, accessTokenSecretName: 'googleAccessToken', refreshTokenSecretName: 'googleRefreshToken', requiredHosts: ['accounts.google.com', 'www.googleapis.com'], From 98b865fe52baf5ea9b47f419668fe3db5d537cff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 06:57:40 +0000 Subject: [PATCH 12/15] fix(integrations): resolve setup prefill by provider family, not slug guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect prefill assumed an app's slug equals the connection name. App resolution dedupes, so setting up a second account persists under the shared app's slug: google-calendar lands on the google app. The fallback looked for a slug named google-calendar, found nothing, and skipped the prefill — leaving the original regression in place for exactly the multi-account case this change exists to support. Resolution is now connection, then exact slug, then provider family. A family can legitimately hold different client ids (spotify and spotify-family do), so prefill only happens when every candidate agrees. Guessing would surface as an opaque provider error at token exchange rather than as a visibly wrong field. Lives in the service rather than the account loader, since the app layer holding its own copy of app resolution is what produced the earlier provider-rewrite and duplicate-app bugs. Co-authored-by: Kent C. Dodds --- .../client/routes/connect-oauth.node.test.ts | 56 +++++ .../account-integrations-data.node.test.ts | 222 ++++++++++++++++++ .../src/app/account-integrations-data.ts | 27 ++- .../account-integrations.node.test.ts | 64 ++++- packages/worker/src/integrations/repo.ts | 17 ++ .../src/integrations/service.node.test.ts | 171 ++++++++++++++ packages/worker/src/integrations/service.ts | 40 ++++ 7 files changed, 580 insertions(+), 17 deletions(-) create mode 100644 packages/worker/src/app/account-integrations-data.node.test.ts diff --git a/packages/worker/client/routes/connect-oauth.node.test.ts b/packages/worker/client/routes/connect-oauth.node.test.ts index 6408d4c1bf..3487e746f5 100644 --- a/packages/worker/client/routes/connect-oauth.node.test.ts +++ b/packages/worker/client/routes/connect-oauth.node.test.ts @@ -476,6 +476,62 @@ test('connect OAuth derives Canva confidential + PKCE basic-form defaults and ho }) }) +test('shared-app family lookup prefills google-calendar from the google app payload', () => { + // integrations.json?name=google-calendar resolves the shared `google` app + // (slug ≠ name) and returns this shape; the client must still merge the + // client id for a fresh multi-account setup session. + const storedFromFamilyLookup = parseStoredIntegrationConfig( + { + name: 'google-calendar', + appSlug: 'google', + provider: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'shared-google-client', + clientSecretSecretName: null, + accessTokenSecretName: 'google-calendarAccessToken', + refreshTokenSecretName: 'google-calendarRefreshToken', + requiredHosts: [], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, + }, + null, + ) + expect(storedFromFamilyLookup?.clientId).toBe('shared-google-client') + + const config = mergeConnectOauthConfig({ + queryConfig: { + provider: 'google-calendar', + providerKey: 'google-calendar', + authorizeHost: 'accounts.google.com', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + scopes: ['calendar.readonly'], + flow: 'pkce', + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: ' ', + extraAuthorizeParams: {}, + providerSetupInstructions: null, + dashboardUrl: null, + allowedHosts: ['www.googleapis.com'], + }, + storedIntegration: storedFromFamilyLookup, + }) + expect(config).toMatchObject({ + provider: 'google-calendar', + clientId: 'shared-google-client', + tokenUrl: 'https://oauth2.googleapis.com/token', + accessTokenSecretName: 'google-calendarAccessToken', + }) +}) + test('abandoned setup still prefills client id from a connectionless app on a fresh session', () => { // This is the regression: after setup we persist the app (not only session // storage). A later visit with no session snapshot must still see the diff --git a/packages/worker/src/app/account-integrations-data.node.test.ts b/packages/worker/src/app/account-integrations-data.node.test.ts new file mode 100644 index 0000000000..5d0e32d985 --- /dev/null +++ b/packages/worker/src/app/account-integrations-data.node.test.ts @@ -0,0 +1,222 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' +import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' +import { + upsertIntegration, + upsertOauthAppWithoutConnection, +} from '#worker/integrations/service.ts' +import { loadAccountIntegrationByName } from './account-integrations-data.ts' + +const migrationsDirectory = new URL('../../migrations/', import.meta.url) + +function applyAllMigrations(db: DatabaseSync) { + for (const fileName of readdirSync(migrationsDirectory) + .filter((file) => file.endsWith('.sql')) + .sort()) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) + } +} + +function createEnv() { + const sqlite = new DatabaseSync(':memory:') + applyAllMigrations(sqlite) + return { + env: { APP_DB: createD1FromSqlite(sqlite) } as Env, + } +} + +function fakeUser(userId: string) { + return { + mcpUser: { userId, email: 'user@example.com', username: 'user' }, + } as Parameters[1] +} + +const googleConfig = { + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + clientId: 'shared-google-client', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + }, +} + +test('loadAccountIntegrationByName prefills shared google client id for google-calendar setup', async () => { + const { env } = createEnv() + const userId = 'user-calendar-setup' + + await upsertIntegration({ + env, + userId, + config: googleConfig, + }) + + const record = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'google-calendar', + ) + expect(record).toMatchObject({ + name: 'google-calendar', + appSlug: 'google', + provider: 'google', + clientId: 'shared-google-client', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: [], + }, + accessTokenSecretName: 'google-calendarAccessToken', + refreshTokenSecretName: 'google-calendarRefreshToken', + }) + // Prefill exposes secret *names* and client id, never token values. + expect(record?.clientSecretSecretName ?? null).toBeNull() + expect(JSON.stringify(record)).not.toMatch( + /"access_token"\s*:|"refresh_token"\s*:|sk_|secret_value/, + ) +}) + +test('loadAccountIntegrationByName returns null for a brand-new provider', async () => { + const { env } = createEnv() + const record = await loadAccountIntegrationByName( + env, + fakeUser('user-new'), + 'linear', + ) + expect(record).toBeNull() +}) + +test('loadAccountIntegrationByName reconnects through the connection, not family fallback', async () => { + const { env } = createEnv() + const userId = 'user-reconnect' + + await upsertIntegration({ + env, + userId, + config: googleConfig, + }) + await upsertIntegration({ + env, + userId, + config: { + ...googleConfig, + name: 'google-calendar', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + authorization: { + ...googleConfig.authorization, + scopes: ['calendar.readonly'], + }, + }, + }) + + const record = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'google-calendar', + ) + expect(record).toMatchObject({ + name: 'google-calendar', + appSlug: 'google', + clientId: 'shared-google-client', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + authorization: { + scopes: ['calendar.readonly'], + }, + }) +}) + +test('loadAccountIntegrationByName does not guess among disagreeing family client ids', async () => { + const { env } = createEnv() + const userId = 'user-spotify-split' + + await upsertIntegration({ + env, + userId, + config: { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce', + clientId: 'spotify-personal-client', + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + await upsertIntegration({ + env, + userId, + config: { + name: 'spotify-family', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce', + clientId: 'spotify-family-client', + accessTokenSecretName: 'spotifyFamilyAccessToken', + refreshTokenSecretName: 'spotifyFamilyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + + const record = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'spotify-kids', + ) + expect(record).toBeNull() +}) + +test('loadAccountIntegrationByName surfaces a connectionless exact-slug setup app', async () => { + const { env } = createEnv() + const userId = 'user-abandoned' + + await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + flow: 'pkce', + clientId: 'spotify-client-from-setup', + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + }, + }, + }) + + const record = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'spotify', + ) + expect(record).toMatchObject({ + name: 'spotify', + appSlug: 'spotify', + clientId: 'spotify-client-from-setup', + tokenUrl: 'https://accounts.spotify.com/api/token', + }) +}) diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index 2e370ba836..295dd52362 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -2,8 +2,8 @@ import { type AccountIntegrationsLoaderData } from '#app/loader-data.ts' import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { canonicalIntegrationName } from '#mcp/capabilities/integrations/integration-shared.ts' import { + findOauthAppForProviderSetup, getJoinedIntegration, - getOauthApp, listJoinedIntegrations, toIntegrationConfig, type IntegrationConfig, @@ -40,14 +40,20 @@ function toAccountIntegrationRecord(input: { } /** - * Convert a connectionless OAuth app (unfinished setup) into the connect-flow - * payload shape. Secret names are defaults so reconnect can proceed; the - * connection row is still created only after token exchange. + * Convert an OAuth app (connectionless setup, or family fallback for a new + * account name) into the connect-flow payload shape. `requestedName` is the + * connection name being set up — it may differ from `app.slug` when the app + * was reused across accounts. Secret names are defaults for the new + * connection; the connection row is still created only after token exchange. */ function toAppOnlyIntegrationRecord( app: UserOauthApp, + requestedName: string, ): AccountIntegrationRecord { - const providerKey = canonicalIntegrationName(app.slug) || app.slug + const providerKey = + canonicalIntegrationName(requestedName) || + canonicalIntegrationName(app.slug) || + app.slug return { name: providerKey, appSlug: app.slug, @@ -108,6 +114,7 @@ export async function loadAccountIntegrationByName( user: AuthenticatedUser, name: string, ): Promise { + // 1. Existing connection (reconnect) — connection name, not app slug. const joined = await getJoinedIntegration({ env, userId: user.mcpUser.userId, @@ -115,13 +122,13 @@ export async function loadAccountIntegrationByName( }) if (joined) return toAccountIntegrationRecord(joined) - // Unfinished setup persists an app with no connection. Surface it for the - // connect flow so a later session still sees the entered client id. - const app = await getOauthApp({ + // 2–3. Exact app slug, else unambiguous provider-family app (shared-app + // multi-account setup where slug may be `google` for `google-calendar`). + const app = await findOauthAppForProviderSetup({ env, userId: user.mcpUser.userId, - slug: name, + name, }) if (!app) return null - return toAppOnlyIntegrationRecord(app) + return toAppOnlyIntegrationRecord(app, name) } diff --git a/packages/worker/src/app/handlers/account-integrations.node.test.ts b/packages/worker/src/app/handlers/account-integrations.node.test.ts index db86df37bc..5446955e97 100644 --- a/packages/worker/src/app/handlers/account-integrations.node.test.ts +++ b/packages/worker/src/app/handlers/account-integrations.node.test.ts @@ -162,7 +162,7 @@ const mockModule = vi.hoisted(() => ({ updatedAt: '1970-01-01T00:00:00.001Z', }, })), - getOauthApp: vi.fn(async () => null), + findOauthAppForProviderSetup: vi.fn(async () => null), })) vi.mock('#app/authenticated-user.ts', () => ({ @@ -191,7 +191,8 @@ vi.mock('#worker/integrations/service.ts', async (importOriginal) => { mockModule.listJoinedIntegrations(...args), getJoinedIntegration: (...args: Array) => mockModule.getJoinedIntegration(...args), - getOauthApp: (...args: Array) => mockModule.getOauthApp(...args), + findOauthAppForProviderSetup: (...args: Array) => + mockModule.findOauthAppForProviderSetup(...args), } }) @@ -355,7 +356,7 @@ test('integrations API returns one connection by name for the connect OAuth flow test('integrations API returns null when a named connection is missing', async () => { mockModule.getJoinedIntegration.mockResolvedValueOnce(null) - mockModule.getOauthApp.mockResolvedValueOnce(null) + mockModule.findOauthAppForProviderSetup.mockResolvedValueOnce(null) const handler = createAccountIntegrationsApiHandler(createEnv()) const response = await handler.handler({ request: new Request( @@ -370,9 +371,9 @@ test('integrations API returns null when a named connection is missing', async ( }) }) -test('integrations API falls back to a connectionless OAuth app so abandoned setup still returns the client id', async () => { +test('integrations API falls back to provider-setup app resolution so abandoned setup still returns the client id', async () => { mockModule.getJoinedIntegration.mockResolvedValueOnce(null) - mockModule.getOauthApp.mockResolvedValueOnce({ + mockModule.findOauthAppForProviderSetup.mockResolvedValueOnce({ userId: 'stable-user-1', slug: 'spotify', provider: 'spotify', @@ -412,9 +413,58 @@ test('integrations API falls back to a connectionless OAuth app so abandoned set }, }, }) - expect(mockModule.getOauthApp).toHaveBeenCalledWith({ + expect(mockModule.findOauthAppForProviderSetup).toHaveBeenCalledWith({ env: expect.any(Object), userId: 'stable-user-1', - slug: 'spotify', + name: 'spotify', + }) +}) + +test('integrations API prefills a shared family app when setting up google-calendar', async () => { + mockModule.getJoinedIntegration.mockResolvedValueOnce(null) + mockModule.findOauthAppForProviderSetup.mockResolvedValueOnce({ + userId: 'stable-user-1', + slug: 'google', + provider: 'google', + label: null, + clientId: 'shared-google-client', + clientSecretSecretName: null, + tokenUrl: 'https://oauth2.googleapis.com/token', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce' as const, + usePkce: null, + tokenExchangeStyle: null, + scopeSeparator: null, + extraAuthorizeParams: { access_type: 'offline' }, + createdAt, + updatedAt, + }) + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request( + 'https://example.com/account/integrations.json?name=google-calendar', + ), + params: {}, + } as never) + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload).toMatchObject({ + ok: true, + integration: { + name: 'google-calendar', + appSlug: 'google', + clientId: 'shared-google-client', + tokenUrl: 'https://oauth2.googleapis.com/token', + accessTokenSecretName: 'google-calendarAccessToken', + }, + }) + expect(JSON.stringify(payload)).not.toMatch( + /"access_token"\s*:|"refresh_token"\s*:|sk_|secret_value/, + ) + expect(mockModule.findOauthAppForProviderSetup).toHaveBeenCalledWith({ + env: expect.any(Object), + userId: 'stable-user-1', + name: 'google-calendar', }) }) diff --git a/packages/worker/src/integrations/repo.ts b/packages/worker/src/integrations/repo.ts index df93e8216f..f8509d6f23 100644 --- a/packages/worker/src/integrations/repo.ts +++ b/packages/worker/src/integrations/repo.ts @@ -121,6 +121,23 @@ export async function getOauthAppBySlug(input: { return row ? mapOauthAppRow(row) : null } +export async function listOauthAppsByProvider(input: { + db: D1Database + userId: string + provider: string +}): Promise> { + const result = await input.db + .prepare( + `SELECT ${appSelectColumns} + FROM user_oauth_apps + WHERE user_id = ? AND provider = ? + ORDER BY slug ASC`, + ) + .bind(input.userId, input.provider) + .all() + return (result.results ?? []).map(mapOauthAppRow) +} + export async function findOauthAppByClientCredentials(input: { db: D1Database userId: string diff --git a/packages/worker/src/integrations/service.node.test.ts b/packages/worker/src/integrations/service.node.test.ts index 2b3d239035..e591cfc137 100644 --- a/packages/worker/src/integrations/service.node.test.ts +++ b/packages/worker/src/integrations/service.node.test.ts @@ -4,6 +4,7 @@ import { expect, test } from 'vitest' import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' import { deleteOauthAppIfUnused, + findOauthAppForProviderSetup, getIntegration, getOauthApp, listIntegrations, @@ -856,3 +857,173 @@ test('upsertOauthAppWithoutConnection matching a connected app does not rewrite clientId: 'shared-google-client', }) }) + +test('findOauthAppForProviderSetup prefills a shared family app for google-calendar', async () => { + const { env } = createEnv() + const userId = 'user-family-prefill' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + + const found = await findOauthAppForProviderSetup({ + env, + userId, + name: 'google-calendar', + }) + expect(found).toMatchObject({ + slug: 'google', + provider: 'google', + clientId: 'google-client-id-value', + tokenUrl: 'https://oauth2.googleapis.com/token', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + }) + // Prefill is app metadata only — no token secret values on the app row. + expect(found).not.toHaveProperty('accessTokenSecretName') + expect(found).not.toHaveProperty('refreshTokenSecretName') + expect(JSON.stringify(found)).not.toMatch( + /"access_token"\s*:|"refresh_token"\s*:|sk_|secret_value/, + ) +}) + +test('findOauthAppForProviderSetup returns null for a brand-new provider', async () => { + const { env } = createEnv() + const found = await findOauthAppForProviderSetup({ + env, + userId: 'user-empty', + name: 'linear', + }) + expect(found).toBeNull() +}) + +test('findOauthAppForProviderSetup prefers an exact slug over family fallback', async () => { + const { env } = createEnv() + const userId = 'user-exact-slug' + + await upsertIntegration({ + env, + userId, + config: baseGoogleConfig, + }) + await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'google-calendar', + tokenUrl: 'https://oauth2.googleapis.com/token/calendar-only', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'calendar-only-client', + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + }, + }, + }) + + const found = await findOauthAppForProviderSetup({ + env, + userId, + name: 'google-calendar', + }) + expect(found).toMatchObject({ + slug: 'google-calendar', + clientId: 'calendar-only-client', + tokenUrl: 'https://oauth2.googleapis.com/token/calendar-only', + }) +}) + +test('findOauthAppForProviderSetup does not guess when family apps disagree on client id', async () => { + const { env } = createEnv() + const userId = 'user-spotify-family' + + await upsertIntegration({ + env, + userId, + config: { + name: 'spotify', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce', + clientId: 'spotify-personal-client', + accessTokenSecretName: 'spotifyAccessToken', + refreshTokenSecretName: 'spotifyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + await upsertIntegration({ + env, + userId, + config: { + name: 'spotify-family', + tokenUrl: 'https://accounts.spotify.com/api/token', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce', + clientId: 'spotify-family-client', + accessTokenSecretName: 'spotifyFamilyAccessToken', + refreshTokenSecretName: 'spotifyFamilyRefreshToken', + requiredHosts: ['api.spotify.com'], + authorization: { + authorizeUrl: 'https://accounts.spotify.com/authorize', + scopes: ['user-read-email'], + scopeSeparator: ' ', + extraAuthorizeParams: {}, + }, + }, + }) + + const apps = await listOauthApps({ env, userId }) + expect(apps.map((app) => app.slug).sort()).toEqual([ + 'spotify', + 'spotify-family', + ]) + expect(new Set(apps.map((app) => app.clientId)).size).toBe(2) + + // No connection named spotify-kids; family has disagreeing client ids. + const found = await findOauthAppForProviderSetup({ + env, + userId, + name: 'spotify-kids', + }) + expect(found).toBeNull() +}) + +test('findOauthAppForProviderSetup returns the exact connectionless app slug', async () => { + const { env } = createEnv() + const userId = 'user-setup-exact' + + await upsertOauthAppWithoutConnection({ + env, + userId, + config: { + name: 'notion', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + flow: 'confidential', + clientId: 'notion-client-from-setup', + clientSecretSecretName: 'notionClientSecret', + authorization: { + authorizeUrl: 'https://api.notion.com/v1/oauth/authorize', + }, + }, + }) + + const found = await findOauthAppForProviderSetup({ + env, + userId, + name: 'notion', + }) + expect(found).toMatchObject({ + slug: 'notion', + clientId: 'notion-client-from-setup', + clientSecretSecretName: 'notionClientSecret', + }) +}) diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 2ed3c63524..02c7fb9837 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -15,6 +15,7 @@ import { getJoinedIntegrationByName, getOauthAppBySlug, listJoinedIntegrationsForUser, + listOauthAppsByProvider, listOauthAppsWithConnectionCounts, updateOauthAppClientCredentials, upsertIntegrationConnection, @@ -311,6 +312,45 @@ export async function getOauthApp(input: { }) } +/** + * Resolve an OAuth app for connect-flow setup when there is no connection + * named `name` yet. Exact slug match first; otherwise the provider family + * derived via `providerFromSlug` (e.g. `google-calendar` → `google`). + * + * Family fallback only prefills when every candidate shares the same + * `clientId`. Differing client ids (e.g. `spotify` vs `spotify-family`) are + * ambiguous — return null rather than guess. + */ +export async function findOauthAppForProviderSetup(input: { + env: Pick + userId: string + name: string +}): Promise { + const slug = canonicalizeOauthAppSlug(input.name) + if (!slug) return null + + const exact = await getOauthAppBySlug({ + db: input.env.APP_DB, + userId: input.userId, + slug, + }) + if (exact) return exact + + const family = providerFromSlug(slug) + const candidates = await listOauthAppsByProvider({ + db: input.env.APP_DB, + userId: input.userId, + provider: family, + }) + if (candidates.length === 0) return null + if (candidates.length === 1) return candidates[0] ?? null + + const clientIds = new Set(candidates.map((app) => app.clientId)) + if (clientIds.size !== 1) return null + + return candidates.find((app) => app.slug === family) ?? candidates[0] ?? null +} + export async function rotateOauthAppClientCredentials(input: { env: Pick userId: string From 80b6c786b711e81b127edd9d773ae3074de8e8b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 07:17:30 +0000 Subject: [PATCH 13/15] fix(integrations): write canonical column values in the backfill The backfill stored several columns exactly as they came out of the legacy JSON, while the application layer normalizes before writing. Where those columns sit in the app tuple, that mismatch means findOauthAppByAppTuple cannot match a migrated row, so a reconnect allocates a fresh app and strands its siblings on the old slug. extra_authorize_params_json now sorts keys, and scope_separator drops the default single space, both of which are compared in the tuple. Also aligned token_exchange_style, client_secret_secret_name, authorize_url, required_hosts_json, and scopes_json with their normalizers, and left a note that SQL BINARY ordering matches localeCompare for the lowercase keys OAuth providers actually use. This is the third instance of the same class after use_pkce, so the tests now assert stored column values rather than round-tripped config: reading through toIntegrationConfig normalizes and hides exactly this defect. Verified by dry-running the migration over the real production blobs: 19 connections collapse to 15 apps, every config matches what the application layer produces from the same input, all client-id values survive, and re-saving a shared-app sibling afterward reuses its app. Co-authored-by: Kent C. Dodds --- .../0101-user-oauth-apps-and-integrations.sql | 88 +++++++- .../src/integrations/migration.node.test.ts | 209 +++++++++++++++++- tools/migration-ledger.json | 2 +- 3 files changed, 286 insertions(+), 13 deletions(-) diff --git a/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql b/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql index c0d6209c58..e01bb13c0a 100644 --- a/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql +++ b/packages/worker/migrations/0101-user-oauth-apps-and-integrations.sql @@ -154,9 +154,15 @@ SELECT e.created_at, e.updated_at, trim(cid.value), - NULLIF(trim(json_extract(e.value, '$.clientSecretSecretName')), ''), + -- Match buildOauthAppRow: client secrets are confidential-only. pkce rows + -- must store NULL so findOauthAppByAppTuple matches application upserts. + CASE + WHEN json_extract(e.value, '$.flow') = 'confidential' + THEN NULLIF(trim(json_extract(e.value, '$.clientSecretSecretName')), '') + ELSE NULL + END, trim(json_extract(e.value, '$.tokenUrl')), - json_extract(e.value, '$.authorization.authorizeUrl'), + NULLIF(trim(json_extract(e.value, '$.authorization.authorizeUrl')), ''), NULLIF(trim(json_extract(e.value, '$.apiBaseUrl')), ''), json_extract(e.value, '$.flow'), -- Match normalizeIntegrationConfig: store usePkce only when it differs @@ -170,14 +176,82 @@ SELECT WHEN json_extract(e.value, '$.usePkce') = 0 THEN 0 ELSE 1 END, - json_extract(e.value, '$.tokenExchangeStyle'), - json_extract(e.value, '$.authorization.scopeSeparator'), + -- Match buildOauthAppRow: 'form' is the default exchange style → NULL. + CASE + WHEN json_extract(e.value, '$.tokenExchangeStyle') IS NULL THEN NULL + WHEN json_extract(e.value, '$.tokenExchangeStyle') = 'form' THEN NULL + ELSE json_extract(e.value, '$.tokenExchangeStyle') + END, + -- Match normalizeIntegrationAuthorization: default scope separator (' ') + -- is stored as NULL. + CASE + WHEN json_extract(e.value, '$.authorization.scopeSeparator') IS NULL + THEN NULL + WHEN json_extract(e.value, '$.authorization.scopeSeparator') = ' ' + THEN NULL + ELSE json_extract(e.value, '$.authorization.scopeSeparator') + END, + -- Match normalizeIntegrationAuthorization: rebuild with sorted keys so the + -- stored JSON is byte-identical to JSON.stringify of the sorted object. + -- ORDER BY key uses SQLite BINARY collation; the app sorts with + -- localeCompare. They agree for the lowercase snake_case OAuth param keys + -- providers use; mixed-case keys could diverge. + -- Missing authorization / missing extraAuthorizeParams / explicit {} all + -- yield '{}' (json_group_object over zero rows returns '{}', not NULL). COALESCE( - json_extract(e.value, '$.authorization.extraAuthorizeParams'), + ( + SELECT json_group_object(key, value) + FROM ( + SELECT trim(key) AS key, value + FROM json_each( + COALESCE( + json_extract( + e.value, + '$.authorization.extraAuthorizeParams' + ), + '{}' + ) + ) + WHERE typeof(key) = 'text' AND trim(key) != '' + ORDER BY key + ) + ), '{}' ), - COALESCE(json_extract(e.value, '$.authorization.scopes'), '[]'), - COALESCE(json_extract(e.value, '$.requiredHosts'), '[]'), + -- Match normalizeIntegrationAuthorization scopes: trim, drop empties, + -- preserve encounter order (not sorted). + COALESCE( + ( + SELECT json_group_array(scope) + FROM ( + SELECT trim(value) AS scope + FROM json_each( + COALESCE( + json_extract(e.value, '$.authorization.scopes'), + '[]' + ) + ) + WHERE typeof(value) = 'text' AND trim(value) != '' + ) + ), + '[]' + ), + -- Match normalizeAllowedHosts: trim, lowercase, dedupe, sort. + COALESCE( + ( + SELECT json_group_array(host) + FROM ( + SELECT lower(trim(value)) AS host + FROM json_each( + COALESCE(json_extract(e.value, '$.requiredHosts'), '[]') + ) + WHERE typeof(value) = 'text' AND trim(value) != '' + GROUP BY host + ORDER BY host + ) + ), + '[]' + ), trim(json_extract(e.value, '$.accessTokenSecretName')), NULLIF(trim(json_extract(e.value, '$.refreshTokenSecretName')), '') FROM value_entries e diff --git a/packages/worker/src/integrations/migration.node.test.ts b/packages/worker/src/integrations/migration.node.test.ts index e5e5b51fb6..948c64c16d 100644 --- a/packages/worker/src/integrations/migration.node.test.ts +++ b/packages/worker/src/integrations/migration.node.test.ts @@ -7,7 +7,11 @@ import { } from '#mcp/capabilities/integrations/integration-shared.ts' import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts' import { getJoinedIntegrationByName } from './repo.ts' -import { toIntegrationConfig } from './service.ts' +import { + listOauthApps, + toIntegrationConfig, + upsertIntegration, +} from './service.ts' /** Historical `_integration:*` value blob shape (pre-0100), with client id as a sibling value name. */ type LegacyIntegrationValue = { @@ -35,7 +39,7 @@ function expectedConfigFromLegacy( clientId: string, ): IntegrationConfig { const { clientIdValueName: _ignored, ...rest } = legacy - return normalizeIntegrationConfig({ + const normalized = normalizeIntegrationConfig({ ...rest, flow: rest.flow as IntegrationConfig['flow'], tokenExchangeStyle: rest.tokenExchangeStyle as @@ -43,6 +47,15 @@ function expectedConfigFromLegacy( | undefined, clientId, }) + // buildOauthAppRow stores client secrets only for confidential apps; the + // migrated row (and therefore toIntegrationConfig) matches that write rule. + return { + ...normalized, + clientSecretSecretName: + normalized.flow === 'confidential' + ? (normalized.clientSecretSecretName ?? null) + : null, + } } const migrationsDirectory = new URL('../../migrations/', import.meta.url) @@ -388,7 +401,7 @@ test('0100 migrates oauth apps/connections with production-shaped dedupe fixture expect(userAApps.find((row) => row.slug === 'google')).toMatchObject({ client_id: 'google-client-id-value', - client_secret_secret_name: 'googleClientSecret', + client_secret_secret_name: null, authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', }) @@ -594,13 +607,13 @@ test('0100 splits apps when credentials match but token_url differs', async () = { slug: 'provider-alpha', client_id: 'same-client-id', - client_secret_secret_name: 'sharedClientSecret', + client_secret_secret_name: null, token_url: 'https://auth.example.com/oauth/token', }, { slug: 'provider-beta', client_id: 'same-client-id', - client_secret_secret_name: 'sharedClientSecret', + client_secret_secret_name: null, token_url: 'https://auth.example.com/oauth/v2/token', }, ]) @@ -929,3 +942,189 @@ test('0101 stores use_pkce matching normalizeIntegrationConfig flow defaults', ( .sort((left, right) => left.slug.localeCompare(right.slug)), ) }) + +test('0101 canonicalizes extra_authorize_params_json key order for tuple matching', async () => { + const db = new DatabaseSync(':memory:') + applyMigrationsBefore(db, oauthAppsMigration) + + insertUserBucket(db, { bucketId: 'bucket-params', userId: 'user-params' }) + insertValue(db, { + bucketId: 'bucket-params', + name: 'google-client-id', + value: 'google-client-id-value', + }) + + // Deliberately reverse-sorted keys — not what JSON.stringify of the + // normalized object would produce. + const reverseSortedParams = { + prompt: 'consent', + include_granted_scopes: 'true', + access_type: 'offline', + } + const sortedParamsJson = JSON.stringify({ + access_type: 'offline', + include_granted_scopes: 'true', + prompt: 'consent', + }) + expect(JSON.stringify(reverseSortedParams)).not.toBe(sortedParamsJson) + + insertValue(db, { + bucketId: 'bucket-params', + name: '_integration:google', + value: JSON.stringify({ + name: 'google', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientIdValueName: 'google-client-id', + clientSecretSecretName: 'googleClientSecret', + accessTokenSecretName: 'googleAccessToken', + refreshTokenSecretName: 'googleRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email'], + scopeSeparator: ' ', + extraAuthorizeParams: reverseSortedParams, + }, + }), + }) + + // Empty / absent shapes must all store '{}'. Distinct token URLs keep + // each case on its own app so we can assert the raw column per slug. + const emptyShapes: Array<{ + name: string + tokenUrl: string + authorization?: { + authorizeUrl: string + scopes: Array + extraAuthorizeParams?: Record + } + }> = [ + { + name: 'params-no-authorization', + tokenUrl: 'https://auth.example.com/token/no-auth', + }, + { + name: 'params-no-extra', + tokenUrl: 'https://auth.example.com/token/no-extra', + authorization: { + authorizeUrl: 'https://auth.example.com/a', + scopes: [], + }, + }, + { + name: 'params-explicit-empty', + tokenUrl: 'https://auth.example.com/token/explicit-empty', + authorization: { + authorizeUrl: 'https://auth.example.com/a', + scopes: [], + extraAuthorizeParams: {}, + }, + }, + ] + insertValue(db, { + bucketId: 'bucket-params', + name: 'empty-client-id', + value: 'empty-client-id-value', + }) + for (const entry of emptyShapes) { + insertValue(db, { + bucketId: 'bucket-params', + name: `_integration:${entry.name}`, + value: JSON.stringify({ + name: entry.name, + tokenUrl: entry.tokenUrl, + flow: 'pkce', + clientIdValueName: 'empty-client-id', + accessTokenSecretName: `${entry.name}AccessToken`, + requiredHosts: ['auth.example.com'], + ...(entry.authorization ? { authorization: entry.authorization } : {}), + }), + }) + } + + applyMigration(db, oauthAppsMigration) + + const googleRow = db + .prepare( + `SELECT slug, extra_authorize_params_json, scope_separator, + client_secret_secret_name, token_exchange_style + FROM user_oauth_apps + WHERE user_id = 'user-params' AND slug = 'google'`, + ) + .get() as { + slug: string + extra_authorize_params_json: string + scope_separator: string | null + client_secret_secret_name: string | null + token_exchange_style: string | null + } + + // Assert the raw column — round-tripping through normalize would hide + // unsorted storage. + expect(googleRow.extra_authorize_params_json).toBe(sortedParamsJson) + expect(googleRow.scope_separator).toBeNull() + expect(googleRow.client_secret_secret_name).toBeNull() + expect(googleRow.token_exchange_style).toBeNull() + + const emptyRows = db + .prepare( + `SELECT slug, extra_authorize_params_json + FROM user_oauth_apps + WHERE user_id = 'user-params' AND slug LIKE 'params-%' + ORDER BY slug ASC`, + ) + .all() as Array<{ slug: string; extra_authorize_params_json: string }> + expect(emptyRows).toEqual([ + { + slug: 'params-explicit-empty', + extra_authorize_params_json: '{}', + }, + { + slug: 'params-no-authorization', + extra_authorize_params_json: '{}', + }, + { + slug: 'params-no-extra', + extra_authorize_params_json: '{}', + }, + ]) + + // The consequence being prevented: a sibling upsert must reuse the + // migrated app rather than allocating a new slug. + const env = { APP_DB: createD1FromSqlite(db) } as Pick + await upsertIntegration({ + env, + userId: 'user-params', + config: { + name: 'google-calendar', + tokenUrl: 'https://oauth2.googleapis.com/token', + apiBaseUrl: 'https://www.googleapis.com', + flow: 'pkce', + clientId: 'google-client-id-value', + accessTokenSecretName: 'googleCalendarAccessToken', + refreshTokenSecretName: 'googleCalendarRefreshToken', + requiredHosts: ['www.googleapis.com'], + authorization: { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['calendar.readonly'], + scopeSeparator: ' ', + extraAuthorizeParams: { + access_type: 'offline', + include_granted_scopes: 'true', + prompt: 'consent', + }, + }, + }, + }) + + const apps = await listOauthApps({ env, userId: 'user-params' }) + const googleFamily = apps.filter((app) => app.provider === 'google') + expect(googleFamily).toHaveLength(1) + expect(googleFamily[0]).toMatchObject({ + slug: 'google', + connectionCount: 2, + clientId: 'google-client-id-value', + }) +}) diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index dc0aa2feda..0c755384b2 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -430,7 +430,7 @@ }, { "filename": "0101-user-oauth-apps-and-integrations.sql", - "sha256": "72c0ab34fab8cd2b204ac6212e8efbba154a4acc6b3f6fe4c79a2415237944fc" + "sha256": "70cf3f8dce4159b57e6dbbd7c929a387d12a2ab078ecdf8180d4ba963074180e" } ] } From 8148d6781c90765098993ae0875e1ec4ae4c3085 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 07:32:24 +0000 Subject: [PATCH 14/15] fix(integrations): agree field by field on provider-family prefill The family fallback treated candidates as interchangeable when they shared a client id, then prefilled everything from whichever app won. Sharing a client id does not imply sharing the rest: github and github-kent hold the same client id but different client-secret names, so a new github-* setup would have been handed the wrong secret name and one app's endpoints. Each field is now prefilled only when every app in the family agrees on it. That keeps the field users actually resent re-entering while never inventing a client-secret name they did not choose. Refusing to prefill at all would be safe but discards the shared client id in a real case, and picking a winner is what produced the wrong default. Co-authored-by: Kent C. Dodds --- .../worker/client/routes/connect-oauth.tsx | 10 +- .../account-integrations-data.node.test.ts | 71 ++++++++- .../src/app/account-integrations-data.ts | 83 +++++----- packages/worker/src/app/loader-data.ts | 8 +- .../src/integrations/service.node.test.ts | 78 +++++++++- packages/worker/src/integrations/service.ts | 142 ++++++++++++++++-- 6 files changed, 331 insertions(+), 61 deletions(-) diff --git a/packages/worker/client/routes/connect-oauth.tsx b/packages/worker/client/routes/connect-oauth.tsx index 7c5a6cecf3..d1f9d6f2d0 100644 --- a/packages/worker/client/routes/connect-oauth.tsx +++ b/packages/worker/client/routes/connect-oauth.tsx @@ -1379,7 +1379,7 @@ export function toStoredIntegrationConfig( name: integration.name, tokenUrl: integration.tokenUrl, apiBaseUrl: integration.apiBaseUrl?.trim() || null, - flow: integration.flow, + ...(integration.flow ? { flow: integration.flow } : {}), usePkce: typeof integration.usePkce === 'boolean' ? integration.usePkce : null, clientId: integration.clientId, @@ -1534,8 +1534,12 @@ export function mergeConnectOauthConfig(input: { input.storedIntegration?.authorization?.authorizeUrl ?? null const authorizeHost = authorizeUrl ? safeParseHost(authorizeUrl) : null + // Empty string means "family prefill could not agree" — fall through to + // the query/default rather than wiping a known endpoint. const tokenUrl = - input.storedIntegration?.tokenUrl ?? input.queryConfig.tokenUrl + input.storedIntegration?.tokenUrl?.trim() || + input.queryConfig.tokenUrl || + null const tokenHost = tokenUrl ? safeParseHost(tokenUrl) : null if ( !provider || @@ -1587,7 +1591,7 @@ export function mergeConnectOauthConfig(input: { extraAuthorizeParams, providerSetupInstructions: input.queryConfig.providerSetupInstructions, dashboardUrl: input.queryConfig.dashboardUrl, - clientId: input.storedIntegration?.clientId ?? '', + clientId: input.storedIntegration?.clientId?.trim() || '', clientSecretSecretName: flow === 'confidential' ? (input.storedIntegration?.clientSecretSecretName ?? diff --git a/packages/worker/src/app/account-integrations-data.node.test.ts b/packages/worker/src/app/account-integrations-data.node.test.ts index 5d0e32d985..ccff55ed73 100644 --- a/packages/worker/src/app/account-integrations-data.node.test.ts +++ b/packages/worker/src/app/account-integrations-data.node.test.ts @@ -137,7 +137,7 @@ test('loadAccountIntegrationByName reconnects through the connection, not family }) }) -test('loadAccountIntegrationByName does not guess among disagreeing family client ids', async () => { +test('loadAccountIntegrationByName omits client id when family apps disagree on it', async () => { const { env } = createEnv() const userId = 'user-spotify-split' @@ -187,7 +187,74 @@ test('loadAccountIntegrationByName does not guess among disagreeing family clien fakeUser(userId), 'spotify-kids', ) - expect(record).toBeNull() + expect(record).toMatchObject({ + name: 'spotify-kids', + clientId: '', + tokenUrl: 'https://accounts.spotify.com/api/token', + flow: 'pkce', + }) +}) + +test('loadAccountIntegrationByName prefills shared github client id but not disagreed secret names', async () => { + const { env } = createEnv() + const userId = 'user-github-split' + + const githubBase = { + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential' as const, + clientId: 'shared-github-client-id', + requiredHosts: ['api.github.com'], + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: ['repo'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + } + + await upsertIntegration({ + env, + userId, + config: { + ...githubBase, + name: 'github', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + }, + }) + await upsertIntegration({ + env, + userId, + config: { + ...githubBase, + name: 'github-kent', + clientSecretSecretName: 'github-kentClientSecret', + accessTokenSecretName: 'githubKentAccessToken', + refreshTokenSecretName: null, + }, + }) + + const record = await loadAccountIntegrationByName( + env, + fakeUser(userId), + 'github-work', + ) + expect(record).toMatchObject({ + name: 'github-work', + clientId: 'shared-github-client-id', + clientSecretSecretName: null, + tokenUrl: 'https://github.com/login/oauth/access_token', + flow: 'confidential', + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + }, + }) + expect(record?.clientSecretSecretName).toBeNull() + expect(JSON.stringify(record)).not.toMatch( + /githubClientSecret|github-kentClientSecret/, + ) }) test('loadAccountIntegrationByName surfaces a connectionless exact-slug setup app', async () => { diff --git a/packages/worker/src/app/account-integrations-data.ts b/packages/worker/src/app/account-integrations-data.ts index 295dd52362..1a4ffbfdd5 100644 --- a/packages/worker/src/app/account-integrations-data.ts +++ b/packages/worker/src/app/account-integrations-data.ts @@ -1,4 +1,4 @@ -import { type AccountIntegrationsLoaderData } from '#app/loader-data.ts' +import { type AccountIntegrationListItem } from '#app/loader-data.ts' import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { canonicalIntegrationName } from '#mcp/capabilities/integrations/integration-shared.ts' import { @@ -6,22 +6,14 @@ import { getJoinedIntegration, listJoinedIntegrations, toIntegrationConfig, - type IntegrationConfig, + type OauthAppSetupPrefill, } from '#worker/integrations/service.ts' -import { type UserOauthApp } from '#worker/integrations/types.ts' type AuthenticatedUser = NonNullable< Awaited> > -export type AccountIntegrationRecord = IntegrationConfig & { - appSlug: string - provider: string - appLabel: string | null - accountLabel: string | null - createdAt: string - updatedAt: string -} +export type AccountIntegrationRecord = AccountIntegrationListItem function toAccountIntegrationRecord(input: { app: Parameters[0] @@ -40,55 +32,62 @@ function toAccountIntegrationRecord(input: { } /** - * Convert an OAuth app (connectionless setup, or family fallback for a new - * account name) into the connect-flow payload shape. `requestedName` is the - * connection name being set up — it may differ from `app.slug` when the app - * was reused across accounts. Secret names are defaults for the new - * connection; the connection row is still created only after token exchange. + * Convert a setup prefill (exact app, sole family member, or field-wise family + * merge) into the connect-flow payload. `requestedName` is the connection name + * being set up — it may differ from `prefill.slug` when the app was reused + * across accounts. Disagreed family fields are omitted / left empty so the UI + * does not invent a secret name or endpoint. */ function toAppOnlyIntegrationRecord( - app: UserOauthApp, + prefill: OauthAppSetupPrefill, requestedName: string, ): AccountIntegrationRecord { const providerKey = canonicalIntegrationName(requestedName) || - canonicalIntegrationName(app.slug) || - app.slug + canonicalIntegrationName(prefill.slug) || + prefill.slug return { name: providerKey, - appSlug: app.slug, - provider: app.provider, - appLabel: app.label, + appSlug: prefill.slug, + provider: prefill.provider, + appLabel: prefill.label, accountLabel: null, - tokenUrl: app.tokenUrl, - apiBaseUrl: app.apiBaseUrl, - flow: app.flow, - ...(typeof app.usePkce === 'boolean' ? { usePkce: app.usePkce } : {}), - clientId: app.clientId, - clientSecretSecretName: app.clientSecretSecretName, + tokenUrl: prefill.tokenUrl ?? '', + apiBaseUrl: prefill.apiBaseUrl, + ...(prefill.flow ? { flow: prefill.flow } : {}), + ...(typeof prefill.usePkce === 'boolean' + ? { usePkce: prefill.usePkce } + : {}), + clientId: prefill.clientId ?? '', + clientSecretSecretName: prefill.clientSecretSecretName, accessTokenSecretName: `${providerKey}AccessToken`, refreshTokenSecretName: `${providerKey}RefreshToken`, requiredHosts: [], - ...(app.tokenExchangeStyle - ? { tokenExchangeStyle: app.tokenExchangeStyle } + ...(prefill.tokenExchangeStyle + ? { tokenExchangeStyle: prefill.tokenExchangeStyle } : {}), - authorization: app.authorizeUrl + authorization: prefill.authorizeUrl ? { - authorizeUrl: app.authorizeUrl, + authorizeUrl: prefill.authorizeUrl, scopes: [], - scopeSeparator: app.scopeSeparator, - extraAuthorizeParams: app.extraAuthorizeParams, + scopeSeparator: prefill.scopeSeparator, + extraAuthorizeParams: prefill.extraAuthorizeParams ?? {}, } : null, - createdAt: app.createdAt, - updatedAt: app.updatedAt, + createdAt: prefill.createdAt, + updatedAt: prefill.updatedAt, } } export async function loadAccountIntegrationsData( env: Env, user: AuthenticatedUser, -): Promise { +): Promise<{ + ok: true + email: string + username: string + integrations: Array +}> { const joined = await listJoinedIntegrations({ env, userId: user.mcpUser.userId, @@ -122,13 +121,13 @@ export async function loadAccountIntegrationByName( }) if (joined) return toAccountIntegrationRecord(joined) - // 2–3. Exact app slug, else unambiguous provider-family app (shared-app - // multi-account setup where slug may be `google` for `google-calendar`). - const app = await findOauthAppForProviderSetup({ + // 2–3. Exact app slug, else field-wise provider-family prefill (shared + // client id across github/github-kent, shared google app, etc.). + const prefill = await findOauthAppForProviderSetup({ env, userId: user.mcpUser.userId, name, }) - if (!app) return null - return toAppOnlyIntegrationRecord(app, name) + if (!prefill) return null + return toAppOnlyIntegrationRecord(prefill, name) } diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index 6f013a71ee..a281819cc3 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -583,10 +583,16 @@ export type AccountIntegrationListItem = { provider: string appLabel: string | null accountLabel: string | null + /** Empty when a provider-family prefill could not agree on token URL. */ tokenUrl: string apiBaseUrl?: string | null - flow: 'pkce' | 'confidential' + /** + * Omitted when a provider-family prefill could not agree on flow so the + * connect UI keeps the query/default flow instead of inventing one. + */ + flow?: 'pkce' | 'confidential' usePkce?: boolean | null + /** Empty when a provider-family prefill could not agree on client id. */ clientId: string clientSecretSecretName?: string | null accessTokenSecretName: string diff --git a/packages/worker/src/integrations/service.node.test.ts b/packages/worker/src/integrations/service.node.test.ts index e591cfc137..410877fe6c 100644 --- a/packages/worker/src/integrations/service.node.test.ts +++ b/packages/worker/src/integrations/service.node.test.ts @@ -858,7 +858,7 @@ test('upsertOauthAppWithoutConnection matching a connected app does not rewrite }) }) -test('findOauthAppForProviderSetup prefills a shared family app for google-calendar', async () => { +test('findOauthAppForProviderSetup prefills every field when the family agrees (google)', async () => { const { env } = createEnv() const userId = 'user-family-prefill' @@ -877,10 +877,12 @@ test('findOauthAppForProviderSetup prefills a shared family app for google-calen slug: 'google', provider: 'google', clientId: 'google-client-id-value', + clientSecretSecretName: null, tokenUrl: 'https://oauth2.googleapis.com/token', authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', apiBaseUrl: 'https://www.googleapis.com', flow: 'pkce', + extraAuthorizeParams: { access_type: 'offline' }, }) // Prefill is app metadata only — no token secret values on the app row. expect(found).not.toHaveProperty('accessTokenSecretName') @@ -890,6 +892,68 @@ test('findOauthAppForProviderSetup prefills a shared family app for google-calen ) }) +test('findOauthAppForProviderSetup prefills shared client id but not disagreed secret names (github)', async () => { + const { env } = createEnv() + const userId = 'user-github-family' + + const githubBase = { + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential' as const, + clientId: 'shared-github-client-id', + requiredHosts: ['api.github.com'], + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: ['repo'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + } + + await upsertIntegration({ + env, + userId, + config: { + ...githubBase, + name: 'github', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + }, + }) + await upsertIntegration({ + env, + userId, + config: { + ...githubBase, + name: 'github-kent', + clientSecretSecretName: 'github-kentClientSecret', + accessTokenSecretName: 'githubKentAccessToken', + refreshTokenSecretName: null, + }, + }) + + const found = await findOauthAppForProviderSetup({ + env, + userId, + name: 'github-work', + }) + expect(found).toMatchObject({ + slug: 'github', + provider: 'github', + clientId: 'shared-github-client-id', + clientSecretSecretName: null, + tokenUrl: 'https://github.com/login/oauth/access_token', + authorizeUrl: 'https://github.com/login/oauth/authorize', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + }) + expect(found?.clientSecretSecretName).toBeNull() + expect(JSON.stringify(found)).not.toMatch( + /githubClientSecret|github-kentClientSecret/, + ) +}) + test('findOauthAppForProviderSetup returns null for a brand-new provider', async () => { const { env } = createEnv() const found = await findOauthAppForProviderSetup({ @@ -936,7 +1000,7 @@ test('findOauthAppForProviderSetup prefers an exact slug over family fallback', }) }) -test('findOauthAppForProviderSetup does not guess when family apps disagree on client id', async () => { +test('findOauthAppForProviderSetup omits client id when family apps disagree on it', async () => { const { env } = createEnv() const userId = 'user-spotify-family' @@ -988,13 +1052,19 @@ test('findOauthAppForProviderSetup does not guess when family apps disagree on c ]) expect(new Set(apps.map((app) => app.clientId)).size).toBe(2) - // No connection named spotify-kids; family has disagreeing client ids. const found = await findOauthAppForProviderSetup({ env, userId, name: 'spotify-kids', }) - expect(found).toBeNull() + expect(found).not.toBeNull() + expect(found?.clientId).toBeNull() + expect(found).toMatchObject({ + tokenUrl: 'https://accounts.spotify.com/api/token', + authorizeUrl: 'https://accounts.spotify.com/authorize', + apiBaseUrl: 'https://api.spotify.com/v1', + flow: 'pkce', + }) }) test('findOauthAppForProviderSetup returns the exact connectionless app slug', async () => { diff --git a/packages/worker/src/integrations/service.ts b/packages/worker/src/integrations/service.ts index 02c7fb9837..625d2bfc35 100644 --- a/packages/worker/src/integrations/service.ts +++ b/packages/worker/src/integrations/service.ts @@ -312,20 +312,45 @@ export async function getOauthApp(input: { }) } +/** + * App-level fields the connect-flow setup UI can prefill. Exact slug / sole + * family member returns every field from that app. Multi-app family fallback + * fills each field only when every candidate agrees; disagreed fields are + * null so the UI does not invent a secret name or endpoint. + */ +export type OauthAppSetupPrefill = { + userId: string + slug: string + provider: string + label: string | null + clientId: string | null + clientSecretSecretName: string | null + tokenUrl: string | null + authorizeUrl: string | null + apiBaseUrl: string | null + flow: UserOauthApp['flow'] | null + usePkce: boolean | null + tokenExchangeStyle: UserOauthApp['tokenExchangeStyle'] + scopeSeparator: string | null + extraAuthorizeParams: Record | null + createdAt: string + updatedAt: string +} + /** * Resolve an OAuth app for connect-flow setup when there is no connection * named `name` yet. Exact slug match first; otherwise the provider family * derived via `providerFromSlug` (e.g. `google-calendar` → `google`). * - * Family fallback only prefills when every candidate shares the same - * `clientId`. Differing client ids (e.g. `spotify` vs `spotify-family`) are - * ambiguous — return null rather than guess. + * Family fallback merges field-by-field: a field is prefilled only when every + * candidate agrees on it (e.g. shared `clientId` with differing secret names + * still prefills the client id and leaves the secret name empty). */ export async function findOauthAppForProviderSetup(input: { env: Pick userId: string name: string -}): Promise { +}): Promise { const slug = canonicalizeOauthAppSlug(input.name) if (!slug) return null @@ -334,7 +359,7 @@ export async function findOauthAppForProviderSetup(input: { userId: input.userId, slug, }) - if (exact) return exact + if (exact) return oauthAppToSetupPrefill(exact) const family = providerFromSlug(slug) const candidates = await listOauthAppsByProvider({ @@ -343,12 +368,111 @@ export async function findOauthAppForProviderSetup(input: { provider: family, }) if (candidates.length === 0) return null - if (candidates.length === 1) return candidates[0] ?? null + if (candidates.length === 1) { + const sole = candidates[0] + return sole ? oauthAppToSetupPrefill(sole) : null + } + + const merged = mergeOauthAppFamilyPrefill({ + userId: input.userId, + family, + candidates, + }) + return setupPrefillHasAgreedField(merged) ? merged : null +} + +function oauthAppToSetupPrefill(app: UserOauthApp): OauthAppSetupPrefill { + return { + userId: app.userId, + slug: app.slug, + provider: app.provider, + label: app.label, + clientId: app.clientId, + clientSecretSecretName: app.clientSecretSecretName, + tokenUrl: app.tokenUrl, + authorizeUrl: app.authorizeUrl, + apiBaseUrl: app.apiBaseUrl, + flow: app.flow, + usePkce: app.usePkce, + tokenExchangeStyle: app.tokenExchangeStyle, + scopeSeparator: app.scopeSeparator, + extraAuthorizeParams: app.extraAuthorizeParams, + createdAt: app.createdAt, + updatedAt: app.updatedAt, + } +} - const clientIds = new Set(candidates.map((app) => app.clientId)) - if (clientIds.size !== 1) return null +function mergeOauthAppFamilyPrefill(input: { + userId: string + family: string + candidates: Array +}): OauthAppSetupPrefill { + const { candidates, family, userId } = input + const first = candidates[0] + if (!first) { + throw new Error( + 'mergeOauthAppFamilyPrefill requires at least one candidate.', + ) + } + return { + userId, + slug: family, + provider: family, + label: null, + clientId: agreedSetupValue(candidates.map((app) => app.clientId)), + clientSecretSecretName: agreedSetupValue( + candidates.map((app) => app.clientSecretSecretName), + ), + tokenUrl: agreedSetupValue(candidates.map((app) => app.tokenUrl)), + authorizeUrl: agreedSetupValue(candidates.map((app) => app.authorizeUrl)), + apiBaseUrl: agreedSetupValue(candidates.map((app) => app.apiBaseUrl)), + flow: agreedSetupValue(candidates.map((app) => app.flow)), + usePkce: agreedSetupValue(candidates.map((app) => app.usePkce)), + tokenExchangeStyle: agreedSetupValue( + candidates.map((app) => app.tokenExchangeStyle), + ), + scopeSeparator: agreedSetupValue( + candidates.map((app) => app.scopeSeparator), + ), + extraAuthorizeParams: agreedSetupValue( + candidates.map((app) => app.extraAuthorizeParams), + sameExtraAuthorizeParams, + ), + createdAt: first.createdAt, + updatedAt: first.updatedAt, + } +} - return candidates.find((app) => app.slug === family) ?? candidates[0] ?? null +function agreedSetupValue( + values: Array, + equal: (left: T, right: T) => boolean = Object.is, +): T | null { + const first = values[0] + if (first === undefined) return null + return values.every((value) => equal(value, first)) ? first : null +} + +function sameExtraAuthorizeParams( + left: Record, + right: Record, +) { + return JSON.stringify(left) === JSON.stringify(right) +} + +function setupPrefillHasAgreedField(prefill: OauthAppSetupPrefill) { + return Boolean( + prefill.clientId || + prefill.clientSecretSecretName || + prefill.tokenUrl || + prefill.authorizeUrl || + prefill.apiBaseUrl || + prefill.flow || + typeof prefill.usePkce === 'boolean' || + prefill.tokenExchangeStyle || + prefill.scopeSeparator || + (prefill.extraAuthorizeParams && + Object.keys(prefill.extraAuthorizeParams).length > 0), + ) } export async function rotateOauthAppClientCredentials(input: { From 7f6e5170b183ccbebfdeba1ce8616cce7d834eb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 07:44:54 +0000 Subject: [PATCH 15/15] refactor(account): drop the unguarded value actions from secrets.json The connect flow was the only caller of value_get and value_set on /account/secrets.json, and it now reads integration config from D1 through its own endpoint. What remained was an authenticated read/write path over arbitrary user values with no reserved-name check, so it could still write legacy _integration: blobs or corrupt a live _openapi: binding while the MCP capability and the account values UI both refuse those prefixes. Removing them is better than adding a third copy of the guard: /account/values already owns value CRUD and applies it. No caller remains anywhere in the client, handlers, or e2e. Co-authored-by: Kent C. Dodds --- .../src/app/handlers/account-secrets.ts | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index 4a29f62544..4bbcdd077c 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -34,7 +34,6 @@ import { type routes } from '#app/routes.ts' import { normalizeAllowedCapabilities } from '#mcp/secrets/allowed-capabilities.ts' import { normalizeAllowedPackages } from '#mcp/secrets/allowed-packages.ts' import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' -import { getValue, saveValue } from '#mcp/values/service.ts' import { canonicalIntegrationName, normalizeIntegrationConfig, @@ -148,12 +147,6 @@ export function createAccountSecretsApiHandler(env: Env) { body, }) } - if (action === 'value_get') { - return handleValueGetAction({ env, user, body }) - } - if (action === 'value_set') { - return handleValueSetAction({ env, user, body }) - } if (action === 'delete') { return handleDeleteAction({ request, @@ -190,55 +183,6 @@ export function createAccountSecretsApiHandler(env: Env) { } satisfies Action } -async function handleValueGetAction(input: { - env: Env - user: NonNullable>> - body: object -}) { - const name = readString(input.body, 'name') - if (!name) { - return jsonResponse({ ok: false, error: 'Value name is required.' }, 400) - } - const value = await getValue({ - env: input.env, - userId: input.user.mcpUser.userId, - name, - scope: 'user', - storageContext: { sessionId: null, appId: null, packageId: null }, - }) - return jsonResponse({ - ok: true, - value: value ? { value: value.value } : null, - }) -} - -async function handleValueSetAction(input: { - env: Env - user: NonNullable>> - body: object -}) { - const name = readString(input.body, 'name') - const value = readString(input.body, 'value') - if (!name || !value) { - return jsonResponse( - { ok: false, error: 'Value name and value are required.' }, - 400, - ) - } - const description = readOptionalString(input.body, 'description') ?? '' - const saved = await saveValue({ - env: input.env, - userId: input.user.mcpUser.userId, - userEmail: input.user.mcpUser.email, - name, - value, - scope: 'user', - description, - storageContext: { sessionId: null, appId: null, packageId: null }, - }) - return jsonResponse({ ok: true, value: { value: saved.value } }) -} - async function handleSaveOauthAppAction(input: { env: Env user: NonNullable>>