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',