From a2f52a87e534348a81bd45a22f851a9cd8d56c04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 20:06:25 +0000 Subject: [PATCH 1/5] Add account integrations page Co-authored-by: Kent C. Dodds --- packages/worker/client/app.tsx | 4 + .../client/routes/account-integrations.tsx | 347 ++++++++++++++++++ packages/worker/client/routes/account.tsx | 16 +- packages/worker/client/routes/index.tsx | 2 + .../account-integrations.node.test.ts | 147 ++++++++ .../src/app/handlers/account-integrations.ts | 114 ++++++ packages/worker/src/app/router.ts | 10 + packages/worker/src/app/routes.ts | 2 + 8 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 packages/worker/client/routes/account-integrations.tsx create mode 100644 packages/worker/src/app/handlers/account-integrations.node.test.ts create mode 100644 packages/worker/src/app/handlers/account-integrations.ts diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index a3f536c2e6..3bb1004603 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -90,6 +90,7 @@ export function App(handle: Handle) { return () => { const isWideLayout = + currentPathname.startsWith('/account/integrations') || currentPathname.startsWith('/account/package-invocation-tokens') || currentPathname.startsWith('/account/remote-connectors') || currentPathname.startsWith('/account/secrets') @@ -183,6 +184,9 @@ export function App(handle: Handle) { Secrets + + Integrations + + scopeSeparator?: string | null + extraAuthorizeParams?: Record +} + +type AccountIntegrationListItem = { + name: string + valueName: string + tokenUrl: string + apiBaseUrl?: string | null + flow: IntegrationFlow + clientIdValueName: string + clientSecretSecretName?: string | null + accessTokenSecretName: string + refreshTokenSecretName?: string | null + requiredHosts?: Array + authorization?: IntegrationAuthorization | null + createdAt: string + updatedAt: string +} + +type AccountIntegrationsPayload = { + ok: true + email: string + username: string + integrations: Array +} + +const accountIntegrationsApiPath = '/account/integrations.json' + +function formatTimestamp(value: string) { + return new Date(value).toLocaleString() +} + +function formatList(values: Array | null | undefined) { + if (!values || values.length === 0) return 'None' + return values.join(', ') +} + +function formatOptional(value: string | null | undefined) { + return value?.trim() ? value : 'None' +} + +function buildConnectOauthHref(integration: AccountIntegrationListItem) { + const authorization = integration.authorization + if (!authorization?.authorizeUrl) return null + + const params = new URLSearchParams({ + provider: integration.name, + authorizeUrl: authorization.authorizeUrl, + tokenUrl: integration.tokenUrl, + flow: integration.flow, + }) + if (integration.apiBaseUrl) { + params.set('apiBaseUrl', integration.apiBaseUrl) + } + if (authorization.scopes.length > 0) { + params.set('scopes', JSON.stringify(authorization.scopes)) + } + if (authorization.scopeSeparator) { + params.set('scopeSeparator', authorization.scopeSeparator) + } + if ( + authorization.extraAuthorizeParams && + Object.keys(authorization.extraAuthorizeParams).length > 0 + ) { + params.set( + 'extraAuthorizeParams', + JSON.stringify(authorization.extraAuthorizeParams), + ) + } + const requiredHosts = integration.requiredHosts ?? [] + if (requiredHosts.length > 0) { + params.set('allowedHosts', requiredHosts.join(',')) + } + + return `/connect/oauth?${params.toString()}` +} + +function IntegrationDetail(props: { label: string; value: string }) { + return ( +
+ {props.label} + {props.value} +
+ ) +} + +export function AccountIntegrationsRoute(handle: Handle) { + let status: AccountStatus = 'loading' + let email = '' + let integrations: Array = [] + let message: string | null = null + let lastLoadedHref = '' + + async function loadIntegrations(signal: AbortSignal) { + try { + const href = + typeof window === 'undefined' + ? '/account/integrations' + : window.location.href + lastLoadedHref = href + const response = await fetch(accountIntegrationsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (signal.aborted) return + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load integrations.') + } + email = payload.email + integrations = payload.integrations + status = 'ready' + message = null + handle.update() + } catch (error) { + if (signal.aborted) return + status = 'error' + message = + error instanceof Error ? error.message : 'Unable to load integrations.' + handle.update() + } + } + + return () => { + const currentHref = + typeof window === 'undefined' + ? '/account/integrations' + : window.location.href + const isRefreshingForLocationChange = + status !== 'loading' && currentHref !== lastLoadedHref + if (status === 'loading' || isRefreshingForLocationChange) { + handle.queueTask(loadIntegrations) + } + + return ( +
+
+

+ {email ? `${email} integrations` : 'Integrations'} +

+

+ Review saved OAuth integrations and reconnect providers when tokens + need to be refreshed. +

+
+ + {status === 'loading' ? ( +

+ Loading integrations... +

+ ) : null} + {message ? ( +

+ {message} +

+ ) : null} + + {status === 'ready' && integrations.length === 0 ? ( +
+

No integrations yet

+

+ OAuth integrations appear here after Kody saves provider + configuration through an OAuth setup flow. +

+
+ ) : null} + + {status === 'ready' && integrations.length > 0 ? ( +
+ {integrations.map((integration) => { + const connectHref = buildConnectOauthHref(integration) + return ( + + ) + })} +
+ ) : null} + +

+ + Back to account + +

+
+ ) + } +} diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx index 2ac13592f2..f100c23c07 100644 --- a/packages/worker/client/routes/account.tsx +++ b/packages/worker/client/routes/account.tsx @@ -161,8 +161,8 @@ export function AccountRoute(handle: Handle) { {username ? `${username} account` : 'Account'}

- Manage your profile, approval requests, stored secrets, and package - invocation tokens. + Manage your profile, integrations, approval requests, stored + secrets, and package invocation tokens.

@@ -235,6 +235,18 @@ export function AccountRoute(handle: Handle) { +
+

Integrations

+

+ Review saved OAuth provider configurations and reconnect + integrations when tokens need to be refreshed. +

+
+ + Manage integrations + +
+

Package invocation tokens

diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index 75d4f2cb90..eb7b365d4f 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -1,4 +1,5 @@ import { AccountRoute } from './account.tsx' +import { AccountIntegrationsRoute } from './account-integrations.tsx' import { AccountPackageInvocationTokensRoute } from './account-package-invocation-tokens.tsx' import { AccountRemoteConnectorsRoute } from './account-remote-connectors.tsx' import { AccountSecretsRoute } from './account-secrets.tsx' @@ -12,6 +13,7 @@ import { ResetPasswordRoute } from './reset-password.tsx' export const clientRoutes = { '/': , '/account': , + '/account/integrations': , '/account/package-invocation-tokens': , '/account/package-invocation-tokens/new': ( diff --git a/packages/worker/src/app/handlers/account-integrations.node.test.ts b/packages/worker/src/app/handlers/account-integrations.node.test.ts new file mode 100644 index 0000000000..839096714c --- /dev/null +++ b/packages/worker/src/app/handlers/account-integrations.node.test.ts @@ -0,0 +1,147 @@ +import { expect, test, vi } from 'vitest' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: '42', + userId: 42, + username: 'test-user', + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: [], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + username: 'test-user', + displayName: 'user', + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listValues: vi.fn(async () => [ + { + name: '_integration:github', + scope: 'user', + value: JSON.stringify({ + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientIdValueName: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + 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, + }, + { + name: '_integration:broken', + scope: 'user', + value: '{', + description: '', + appId: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + ttlMs: null, + }, + { + name: 'plain-value', + scope: 'user', + value: 'ignored', + description: '', + appId: 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), +})) + +vi.mock('#app/auth-session.ts', () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})) + +vi.mock('#app/auth-redirect.ts', () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), +})) + +vi.mock('#app/layout.ts', () => ({ + Layout: () => null, +})) + +vi.mock('#app/render.ts', () => ({ + render: () => new Response('ok'), +})) + +vi.mock('#mcp/values/service.ts', () => ({ + listValues: (...args: Array) => mockModule.listValues(...args), +})) + +const { createAccountIntegrationsApiHandler } = + await import('./account-integrations.ts') + +function createEnv() { + return { + APP_DB: {} as D1Database, + SECRET_STORE_KEY: 'x'.repeat(32), + } as Env +} + +test('integrations API lists valid user-scoped OAuth integrations', async () => { + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request('https://example.com/account/integrations.json'), + params: {}, + } as never) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(mockModule.listValues).toHaveBeenCalledWith({ + env: expect.any(Object), + userId: 'stable-user-1', + scope: 'user', + storageContext: { sessionId: null, appId: null }, + }) + await expect(response.json()).resolves.toEqual({ + ok: true, + email: 'user@example.com', + username: 'test-user', + integrations: [ + { + name: 'github', + valueName: '_integration:github', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientIdValueName: 'github-client-id', + 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, + }, + ], + }) +}) diff --git a/packages/worker/src/app/handlers/account-integrations.ts b/packages/worker/src/app/handlers/account-integrations.ts new file mode 100644 index 0000000000..c232bdd5e0 --- /dev/null +++ b/packages/worker/src/app/handlers/account-integrations.ts @@ -0,0 +1,114 @@ +import { type Action } from 'remix/router' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { Layout } from '#app/layout.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' +import { + type IntegrationConfig, + parseIntegrationConfig, + parseIntegrationJson, + parseIntegrationValueName, +} from '#mcp/capabilities/values/integration-shared.ts' +import { listValues } from '#mcp/values/service.ts' + +type AuthenticatedUser = NonNullable< + Awaited> +> + +type AccountIntegrationListItem = IntegrationConfig & { + valueName: string + createdAt: string + updatedAt: string +} + +type AccountIntegrationsPayload = { + ok: true + email: string + username: string + integrations: Array +} + +export function createAccountIntegrationsHandler(_env: Env) { + return { + middleware: [], + async handler({ request }) { + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const response = render(Layout({ title: 'Integrations' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response + }, + } satisfies Action +} + +export function createAccountIntegrationsApiHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401) + } + + if (request.method !== 'GET') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + return jsonResponse(await buildPayload({ env, user })) + }, + } satisfies Action +} + +async function buildPayload(input: { + env: Env + user: AuthenticatedUser +}): Promise { + const values = await listValues({ + env: input.env, + userId: input.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, + } + }) + .filter((entry): entry is AccountIntegrationListItem => Boolean(entry)) + .sort((left, right) => left.name.localeCompare(right.name)) + + return { + ok: true, + email: input.user.email, + username: input.user.username, + integrations, + } +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index ff4c0e59bb..291969fd62 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -1,6 +1,10 @@ import { createRouter } from 'remix/router' import { account } from '#app/handlers/account.ts' import { createAccountDeleteHandler } from '#app/handlers/account-delete.ts' +import { + createAccountIntegrationsApiHandler, + createAccountIntegrationsHandler, +} from '#app/handlers/account-integrations.ts' import { createAccountPackageInvocationTokensApiHandler, createAccountPackageInvocationTokensHandler, @@ -47,6 +51,12 @@ export function createAppRouter(appEnv: AppEnv) { signup, account, accountDelete: createAccountDeleteHandler(appEnv as unknown as Env), + accountIntegrations: createAccountIntegrationsHandler( + appEnv as unknown as Env, + ), + accountIntegrationsApi: createAccountIntegrationsApiHandler( + appEnv as unknown as Env, + ), accountPackageInvocationTokens: createAccountPackageInvocationTokensHandler(appEnv as unknown as Env), accountPackageInvocationTokenNew: diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 0d7eb9d13c..c812a70b4a 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -3,6 +3,8 @@ import { post, route } from 'remix/routes' export const routes = route({ home: '/', connectOauth: '/connect/oauth', + accountIntegrations: '/account/integrations', + accountIntegrationsApi: '/account/integrations.json', accountPackageInvocationTokens: '/account/package-invocation-tokens', accountPackageInvocationTokenNew: '/account/package-invocation-tokens/new', accountPackageInvocationTokensApi: '/account/package-invocation-tokens.json', From 9a33360555c3876a908313bc8be78373f03922ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 20:07:22 +0000 Subject: [PATCH 2/5] Fix integrations page detail rendering Co-authored-by: Kent C. Dodds --- .../client/routes/account-integrations.tsx | 89 +++++++++---------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index ea4c135f8d..8119f21060 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -105,11 +105,11 @@ function buildConnectOauthHref(integration: AccountIntegrationListItem) { return `/connect/oauth?${params.toString()}` } -function IntegrationDetail(props: { label: string; value: string }) { +function renderIntegrationDetail(label: string, value: string) { return (

- {props.label} - {props.value} + {label} + {value}
) } @@ -254,61 +254,52 @@ export function AccountIntegrationsRoute(handle: Handle) {
- - - - + {renderIntegrationDetail('Token URL', integration.tokenUrl)} + {renderIntegrationDetail( + 'API base URL', + formatOptional(integration.apiBaseUrl), + )} + {renderIntegrationDetail( + 'Authorize URL', + formatOptional(integration.authorization?.authorizeUrl), + )} + {renderIntegrationDetail( + 'Scopes', + formatList(integration.authorization?.scopes), + )}

Stored names

- - - - + {renderIntegrationDetail( + 'Client ID value', + integration.clientIdValueName, + )} + {renderIntegrationDetail( + 'Client secret', + formatOptional(integration.clientSecretSecretName), + )} + {renderIntegrationDetail( + 'Access token secret', + integration.accessTokenSecretName, + )} + {renderIntegrationDetail( + 'Refresh token secret', + formatOptional(integration.refreshTokenSecretName), + )}
- - + {renderIntegrationDetail( + 'Required hosts', + formatList(integration.requiredHosts), + )} + {renderIntegrationDetail( + 'Updated', + formatTimestamp(integration.updatedAt), + )}
{connectHref ? ( From e6bf239b02a92c7d513085a9e7b2fb634b6a4963 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 20:13:45 +0000 Subject: [PATCH 3/5] Wrap integration detail values Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/account-integrations.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 8119f21060..2608af74e7 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -109,7 +109,15 @@ function renderIntegrationDetail(label: string, value: string) { return (
{label} - {value} + + {value} +
) } From 39ed019232110118fc0fefdcbeff1e1b672a9d29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 20:18:04 +0000 Subject: [PATCH 4/5] Fix integrations page URL wrapping Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/account-integrations.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 2608af74e7..8ca5d2940a 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -107,13 +107,17 @@ function buildConnectOauthHref(integration: AccountIntegrationListItem) { function renderIntegrationDetail(label: string, value: string) { return ( -
+
{label} {value} From 5a388ac92cc5f785f62741501c30ae25e3298c48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 19 Jun 2026 20:40:44 +0000 Subject: [PATCH 5/5] Avoid stale reconnect OAuth params Co-authored-by: Kent C. Dodds --- .../client/routes/account-integrations.tsx | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 8ca5d2940a..42f341f6ca 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -73,35 +73,7 @@ function buildConnectOauthHref(integration: AccountIntegrationListItem) { const authorization = integration.authorization if (!authorization?.authorizeUrl) return null - const params = new URLSearchParams({ - provider: integration.name, - authorizeUrl: authorization.authorizeUrl, - tokenUrl: integration.tokenUrl, - flow: integration.flow, - }) - if (integration.apiBaseUrl) { - params.set('apiBaseUrl', integration.apiBaseUrl) - } - if (authorization.scopes.length > 0) { - params.set('scopes', JSON.stringify(authorization.scopes)) - } - if (authorization.scopeSeparator) { - params.set('scopeSeparator', authorization.scopeSeparator) - } - if ( - authorization.extraAuthorizeParams && - Object.keys(authorization.extraAuthorizeParams).length > 0 - ) { - params.set( - 'extraAuthorizeParams', - JSON.stringify(authorization.extraAuthorizeParams), - ) - } - const requiredHosts = integration.requiredHosts ?? [] - if (requiredHosts.length > 0) { - params.set('allowedHosts', requiredHosts.join(',')) - } - + const params = new URLSearchParams({ provider: integration.name }) return `/connect/oauth?${params.toString()}` }