Skip to content
6 changes: 6 additions & 0 deletions docs/guides/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ and the current client credentials.

## Integration naming convention

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:<canonical-name>`.

Prefer integration names like `<provider>-<purpose>` 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
Expand Down
14 changes: 6 additions & 8 deletions packages/worker/client/routes/connect-oauth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { expect, test } from 'vitest'
import {
buildIntegrationValueName,
formatOAuthExchangeFailure,
getIntegrationValueCandidates,
isOAuthExchangeSessionExpired,
mergeConnectOauthConfig,
parseSessionConnectOauthConfig,
Expand Down Expand Up @@ -45,13 +44,12 @@ test('connect OAuth helpers parse stored integrations, merge reconnect configs,
requiredHosts: ['api.github.com', 'github.com'],
authorization: null,
})
expect(getIntegrationValueCandidates('GitHub', 'github')).toEqual([
buildIntegrationValueName('GitHub'),
buildIntegrationValueName('github'),
])
expect(getIntegrationValueCandidates('github', 'github')).toEqual([
buildIntegrationValueName('github'),
])
// One canonical value key regardless of how the caller cases the provider.
expect(buildIntegrationValueName('GitHub')).toBe('_integration:github')
expect(buildIntegrationValueName('github')).toBe('_integration:github')
expect(buildIntegrationValueName('Spotify Family')).toBe(
'_integration:spotify-family',
)

const githubConfig = mergeConnectOauthConfig({
queryConfig: {
Expand Down
47 changes: 15 additions & 32 deletions packages/worker/client/routes/connect-oauth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -436,24 +436,16 @@ export function ConnectOauthRoute(handle: Handle) {
const readExistingIntegrationConfig = async (
queryConfig: ConnectOauthQueryConfig,
) => {
for (const valueName of getIntegrationValueCandidates(
queryConfig.provider,
queryConfig.providerKey,
)) {
const raw = await readValue(valueName)
if (!raw) continue
const parsed = parseStoredIntegrationConfig(raw, queryConfig.provider)
if (parsed) {
return {
valueName,
integration: parsed,
}
}
}
return {
valueName: null,
integration: null,
}
// Integration identity is the canonical provider key, so a single
// deterministic lookup replaces the historical raw-name probe.
const valueName = buildIntegrationValueName(queryConfig.providerKey)
const raw = await readValue(valueName)
const parsed = raw
? parseStoredIntegrationConfig(raw, queryConfig.provider)
: null
return parsed
? { valueName, integration: parsed }
: { valueName: null, integration: null }
}

const initializeSetupState = async (nextConfig: ConnectOauthConfig) => {
Expand Down Expand Up @@ -1258,21 +1250,12 @@ function normalizeHosts(hosts: Array<string>) {
).sort()
}

/**
* Integration identity is the canonical provider key; mirrors
* buildIntegrationValueName in integration-shared.ts.
*/
export function buildIntegrationValueName(provider: string) {
return `_integration:${provider}`
}

export function getIntegrationValueCandidates(
provider: string,
providerKey: string,
) {
return Array.from(
new Set(
[provider.trim(), providerKey.trim()]
.filter((value) => value.length > 0)
.map((value) => buildIntegrationValueName(value)),
),
)
return `_integration:${normalizeProviderKey(provider)}`
}

export function parseStoredIntegrationConfig(
Expand Down
8 changes: 4 additions & 4 deletions packages/worker/src/app/handlers/account-secrets.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,15 @@ test('connect oauth saves tokens, integration metadata, and host approval links'
'https://example.com/account/secrets/user/githubRefreshToken?allowed-host=github.com',
},
],
integrationName: 'GitHub',
integrationName: 'github',
})
expect(mockModule.buildSecretHostApprovalUrl).toHaveBeenCalledTimes(4)
expect(mockModule.setSecretAllowedHosts).not.toHaveBeenCalled()
expect(mockModule.saveValue).toHaveBeenCalledWith(
expect.objectContaining({
name: '_integration:GitHub',
name: '_integration:github',
value: JSON.stringify({
name: 'GitHub',
name: 'github',
tokenUrl: 'https://github.com/login/oauth/access_token',
apiBaseUrl: 'https://api.github.com',
flow: 'pkce',
Expand Down Expand Up @@ -316,7 +316,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links'
accessTokenSaved: true,
refreshTokenSaved: true,
hostApprovalLinks: [],
integrationName: 'Tesla',
integrationName: 'tesla',
})
expect(teslaPayload.allowedHosts).toEqual(
expect.arrayContaining([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { expect, test } from 'vitest'
import { createMcpCallerContext } from '#mcp/context.ts'
import { integrationSaveCapability } from './integration-save.ts'
import {
buildIntegrationValueName,
integrationConfigSchema,
mergeIntegrationConfig,
parseIntegrationConfig,
parseIntegrationValueName,
} from './integration-shared.ts'

function createValueTestDb() {
Expand Down Expand Up @@ -348,3 +350,80 @@ test('integration_save persists usePkce only when it differs from the flow defau
JSON.parse(defaultDb.entries.get('_integration:spotify') ?? '{}'),
).not.toHaveProperty('usePkce')
})

test('integration identity is the canonical provider key across save, lookup, and value-name parsing', async () => {
// Saving with display casing stores and returns the canonical name.
const casedDb = createValueTestDb()
const saved = await integrationSaveCapability.handler(
{
name: 'GitHub',
tokenUrl: 'https://github.com/login/oauth/access_token',
flow: 'confidential',
clientIdValueName: 'github-client-id',
clientSecretSecretName: 'githubClientSecret',
accessTokenSecretName: 'githubAccessToken',
requiredHosts: ['api.github.com'],
},
{
env: { APP_DB: casedDb.db } as unknown as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-123' },
}),
},
)
expect(saved.integration.name).toBe('github')
expect(casedDb.entries.has('_integration:github')).toBe(true)
expect(casedDb.entries.has('_integration:GitHub')).toBe(false)

// Every value-key derivation goes through the same canonicalization.
expect(buildIntegrationValueName('GitHub')).toBe('_integration:github')
expect(buildIntegrationValueName('Spotify Family')).toBe(
'_integration:spotify-family',
)

// Only canonical stored keys parse as integrations.
expect(parseIntegrationValueName('_integration:github')).toBe('github')
expect(parseIntegrationValueName('_integration:GitHub')).toBeNull()
expect(parseIntegrationValueName('_integration:')).toBeNull()
expect(parseIntegrationValueName('value:github')).toBeNull()

// Names without any letters or numbers are rejected outright — including
// ones made only of characters the canonical form preserves (. _ -).
await expect(
integrationSaveCapability.handler(
{
name: '._-',
tokenUrl: 'https://example.com/token',
flow: 'pkce',
clientIdValueName: 'x-client-id',
accessTokenSecretName: 'xAccessToken',
},
{
env: { APP_DB: createValueTestDb().db } as unknown as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-123' },
}),
},
),
).rejects.toThrow(/letters or numbers/i)
await expect(
integrationSaveCapability.handler(
{
name: '!!!',
tokenUrl: 'https://example.com/token',
flow: 'pkce',
clientIdValueName: 'x-client-id',
accessTokenSecretName: 'xAccessToken',
},
{
env: { APP_DB: createValueTestDb().db } as unknown as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-123' },
}),
},
),
).rejects.toThrow(/letters or numbers/i)
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const integrationSaveCapability = defineDomainCapability(
{
name: 'integration_save',
description:
'Create or update an OAuth integration configuration for the signed-in user. Stored as a user-scoped value with a _integration: prefix.',
'Create or update an OAuth integration configuration for the signed-in user. Names are normalized to a canonical lowercase-kebab provider key and stored as a user-scoped value with a _integration: prefix.',
keywords: [
'integration',
'oauth',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { normalizeProviderKey } from '@kody-internal/shared/url-hosts.ts'
import { z } from 'zod'
import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts'

export const integrationFlowValues = ['pkce', 'confidential'] as const

/**
* Integration identity is the canonical provider key (lowercase kebab via
* normalizeProviderKey). Every read and write path derives names and value
* keys through this one function, so lookups never depend on how a caller
* cased or spaced the provider name.
*/
export function canonicalIntegrationName(name: string) {
return normalizeProviderKey(name)
}

const integrationNameSchema = z
.string()
.min(1)
.refine((name) => /[a-z0-9]/.test(canonicalIntegrationName(name)), {
message: 'Integration name must contain letters or numbers.',
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const defaultIntegrationScopeSeparator = ' '

export const tokenExchangeStyleValues = [
Expand Down Expand Up @@ -30,7 +48,7 @@ export const integrationAuthorizationSchema = z
.strict()

export const integrationConfigSchema = z.object({
name: z.string().min(1),
name: integrationNameSchema,
tokenUrl: z.string().url(),
apiBaseUrl: z.string().url().optional().nullable(),
flow: z.enum(integrationFlowValues),
Expand All @@ -54,7 +72,7 @@ type IntegrationAuthorization = z.infer<typeof integrationAuthorizationSchema>

export const integrationSaveSchema = z
.object({
name: z.string().min(1),
name: integrationNameSchema,
tokenUrl: z.string().url().optional(),
apiBaseUrl: z.string().url().nullable().optional(),
flow: z.enum(integrationFlowValues).optional(),
Expand Down Expand Up @@ -86,7 +104,7 @@ export function normalizeIntegrationConfig(
? value.usePkce
: null
return {
name: value.name.trim(),
name: canonicalIntegrationName(value.name),
tokenUrl: value.tokenUrl.trim(),
apiBaseUrl: value.apiBaseUrl?.trim() || null,
flow: value.flow,
Expand Down Expand Up @@ -142,13 +160,21 @@ export function mergeIntegrationConfig(
const integrationValuePrefix = '_integration:'

export function buildIntegrationValueName(name: string) {
return `${integrationValuePrefix}${name}`
return `${integrationValuePrefix}${canonicalIntegrationName(name)}`
}

export function parseIntegrationValueName(name: string) {
if (!name.startsWith(integrationValuePrefix)) return null
const integrationName = name.slice(integrationValuePrefix.length).trim()
return integrationName.length > 0 ? integrationName : 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(
Expand Down
Loading