@@ -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>>