diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md index 1a4e1e7163..c944ec39ff 100644 --- a/docs/contributing/architecture/data-storage.md +++ b/docs/contributing/architecture/data-storage.md @@ -93,8 +93,8 @@ that two different users always resolve to two different object ids: `packages/worker/src/package-runtime/`. - `RemoteConnectorSession` — `userScopedConnectorSessionKey(userId, kind, instanceId)`. Connectors must - connect through the new user-scoped ingress URL - `/connectors/u/{userId}/{kind}/{instanceId}`. The DO carries the ingress user + connect through the username-scoped ingress URL + `/@{username}/connectors/{kind}/{instanceId}`. The DO carries the ingress user id forward via headers + websocket attachment and verifies the shared secret against that user's row only. - `AgentTurnRunner` — `idFromName(JSON.stringify([userId, sessionId]))`. The diff --git a/docs/contributing/architecture/remote-connectors.md b/docs/contributing/architecture/remote-connectors.md index 50979e4350..5d86c1212e 100644 --- a/docs/contributing/architecture/remote-connectors.md +++ b/docs/contributing/architecture/remote-connectors.md @@ -8,7 +8,7 @@ calls to JSON-RPC on the socket. ## URLs and session keys -`wss:///connectors/u///` +`wss:///@/connectors//` Session key = JSON tuple `[userId, kind, instanceId]` where `kind` is lowercase after trim. @@ -112,7 +112,7 @@ Source: `packages/shared/src/chat.ts`, ## Connector checklist 1. **Outbound WebSocket** to your connector URL: - `wss:///connectors/u///`. + `wss:///@/connectors//`. 2. **Hello first** with matching **`connectorKind`** + **`connectorId`** and a **valid `sharedSecret`** for that `kind:instanceId` pair. 3. Implement **`tools/list`** and **`tools/call`** on the socket via diff --git a/docs/contributing/architecture/request-lifecycle.md b/docs/contributing/architecture/request-lifecycle.md index 5a23e3bc07..dbcce79454 100644 --- a/docs/contributing/architecture/request-lifecycle.md +++ b/docs/contributing/architecture/request-lifecycle.md @@ -39,8 +39,8 @@ Requests are handled in this order: 6. Remote connector session endpoints (internal-only Worker routes that proxy WebSocket upgrades and JSON-RPC helper requests to the remote connector session Durable Object): - - `/connectors/:kind/:instanceId...` — **`kind`** + instance (session key - `kind:instanceId`) + - `/@{username}/connectors/:kind/:instanceId...` — username + **`kind`** + + instance See [Remote connectors](./remote-connectors.md). diff --git a/docs/contributing/package-invocation-api.md b/docs/contributing/package-invocation-api.md index 84da3521c8..e4104874e7 100644 --- a/docs/contributing/package-invocation-api.md +++ b/docs/contributing/package-invocation-api.md @@ -16,17 +16,13 @@ socket lifecycle. ## Endpoint -`POST /api/package-invocations/:packageIdOrKodyId/:exportName` +`POST /@:username/api/package-invocations/:kodyId/:exportName` Examples: -- `/api/package-invocations/pkg_123/dispatch-message-created` -- `/api/package-invocations/discord-gateway/dispatch-message-created` +- `/@kent/api/package-invocations/discord-gateway/dispatch-message-created` -The path accepts either: - -- the saved package id -- the package `kody.id` +The path uses the owner's username and the package `kody.id`. The export name is normalized to package export form, so `dispatch-message-created` resolves as `./dispatch-message-created`. @@ -193,7 +189,7 @@ curl --fail --silent \ -X POST \ -H "Authorization: Bearer $PACKAGE_INVOCATION_TOKEN" \ -H "Content-Type: application/json" \ - "https://kody.example.com/api/package-invocations/discord-gateway/dispatch-message-created" \ + "https://kody.example.com/@kent/api/package-invocations/discord-gateway/dispatch-message-created" \ -d '{ "params": { "messageId": "123", diff --git a/docs/contributing/security.md b/docs/contributing/security.md index 55665caf20..d0f05fdf85 100644 --- a/docs/contributing/security.md +++ b/docs/contributing/security.md @@ -6,8 +6,8 @@ audit for findings and rationale. ## Public connector routes are WebSocket-only The Worker entrypoint (`packages/worker/src/index.ts`) only forwards user-scoped -connector route requests (`/connectors/u/{userId}/{kind}/{instanceId}`) when the -request carries a `WebSocket` upgrade header. Non-upgrade HTTP requests and +connector route requests (`/@{username}/connectors/{kind}/{instanceId}`) when +the request carries a `WebSocket` upgrade header. Non-upgrade HTTP requests and unmatched `/connectors/*` paths are rejected with `404` before reaching static assets or the Durable Object. diff --git a/e2e/remote-connectors.spec.ts b/e2e/remote-connectors.spec.ts index f3850fd286..acb615a9f6 100644 --- a/e2e/remote-connectors.spec.ts +++ b/e2e/remote-connectors.spec.ts @@ -25,7 +25,7 @@ test('remote connector form reloads, reveals, and generates shared secrets', asy await page.getByLabel('Kind').fill(kind) await page.getByLabel('Instance ID').fill(instanceId) const connectorUrl = page.getByText( - new RegExp(`/connectors/u/[^/]+/${kind}/${instanceId}$`), + new RegExp(`/@[^/]+/connectors/${kind}/${instanceId}$`), ) await expect(connectorUrl).toBeVisible() await page.getByRole('textbox', { name: 'Shared secret' }).fill(sharedSecret) @@ -53,7 +53,7 @@ test('remote connector form reloads, reveals, and generates shared secrets', asy await page.getByRole('button', { name: /copy connector url/i }).click() await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toMatch(new RegExp(`/connectors/u/[^/]+/${kind}/${instanceId}$`)) + .toMatch(new RegExp(`/@[^/]+/connectors/${kind}/${instanceId}$`)) await page.getByRole('button', { name: 'Show shared secret' }).click() await expect(sharedSecretInput).toHaveAttribute('type', 'text') diff --git a/packages/shared/src/chat.ts b/packages/shared/src/chat.ts index 44b148466b..366bfc3504 100644 --- a/packages/shared/src/chat.ts +++ b/packages/shared/src/chat.ts @@ -34,6 +34,7 @@ const remoteConnectorInstanceIdFieldSchema = createSchema( export const mcpUserContextSchema = object({ userId: string(), email: string(), + username: optional(string()), displayName: string(), }) diff --git a/packages/shared/src/public-urls.ts b/packages/shared/src/public-urls.ts new file mode 100644 index 0000000000..129b79f43c --- /dev/null +++ b/packages/shared/src/public-urls.ts @@ -0,0 +1,35 @@ +export function buildUsernamePathPrefix(username: string) { + return `/@${encodeURIComponent(username.trim())}` +} + +export function buildPackageAppPath(input: { + username: string + kodyId: string + restPath?: string | null +}) { + const restPath = input.restPath?.trim() + const suffix = restPath ? `/${restPath.replace(/^\/+/, '')}` : '' + return `${buildUsernamePathPrefix(input.username)}/packages/${encodeURIComponent( + input.kodyId.trim(), + )}${suffix}` +} + +export function buildPackageAppUrl(input: { + origin: string + username: string + kodyId: string + restPath?: string | null +}) { + return `${input.origin.trim().replace(/\/+$/, '')}${buildPackageAppPath(input)}` +} + +export function requireUsernameForPublicUrl( + username: string | null | undefined, +) { + if (!username) { + throw new Error( + 'Username is required to build username-scoped public URLs.', + ) + } + return username +} diff --git a/packages/shared/src/remote-connectors.ts b/packages/shared/src/remote-connectors.ts index ffdff6b314..8c790cfe7d 100644 --- a/packages/shared/src/remote-connectors.ts +++ b/packages/shared/src/remote-connectors.ts @@ -1,5 +1,6 @@ import { type InferOutput } from 'remix/data-schema' import { type mcpCallerContextSchema } from './chat.ts' +import { buildUsernamePathPrefix } from './public-urls.ts' type McpCallerContext = InferOutput @@ -17,21 +18,20 @@ export function normalizeRemoteConnectorInstanceId(instanceId: string): string { } export function userScopedConnectorIngressPath(input: { - userId: string + username: string kind: string instanceId: string }) { - const userId = encodeURIComponent(input.userId.trim()) const kind = encodeURIComponent(normalizeRemoteConnectorKind(input.kind)) const instanceId = encodeURIComponent( normalizeRemoteConnectorInstanceId(input.instanceId), ) - return `/connectors/u/${userId}/${kind}/${instanceId}` + return `${buildUsernamePathPrefix(input.username)}/connectors/${kind}/${instanceId}` } export function userScopedConnectorWebSocketUrl(input: { origin: string - userId: string + username: string kind: string instanceId: string }) { diff --git a/packages/worker/client/routes/account-remote-connectors.tsx b/packages/worker/client/routes/account-remote-connectors.tsx index 06aa7eb23e..8e094362f8 100644 --- a/packages/worker/client/routes/account-remote-connectors.tsx +++ b/packages/worker/client/routes/account-remote-connectors.tsx @@ -40,7 +40,7 @@ type RemoteConnectorListItem = { type AccountRemoteConnectorsPayload = { ok: true email: string - userId: string + username: string connectorUrlOrigin: string connectors: Array selectedConnectorId?: string @@ -293,7 +293,7 @@ export function AccountRemoteConnectorsRoute(handle: Handle) { let status: AccountStatus = 'loading' let saveState: 'idle' | 'saving' | 'deleting' = 'idle' let email = '' - let userId = '' + let username = '' let connectorUrlOrigin = '' let connectors: Array = [] let editorState = createEmptyEditorState() @@ -344,7 +344,7 @@ export function AccountRemoteConnectorsRoute(handle: Handle) { function applyPayload(payload: AccountRemoteConnectorsPayload) { email = payload.email - userId = payload.userId + username = payload.username connectorUrlOrigin = payload.connectorUrlOrigin connectors = payload.connectors deleteConfirm = false @@ -529,11 +529,11 @@ export function AccountRemoteConnectorsRoute(handle: Handle) { } function getEditorConnectorUrl() { - if (!userId || !connectorUrlOrigin) return null + if (!username || !connectorUrlOrigin) return null if (!editorState.kind.trim() || !editorState.instanceId.trim()) return null return userScopedConnectorWebSocketUrl({ origin: connectorUrlOrigin, - userId, + username, kind: editorState.kind, instanceId: editorState.instanceId, }) @@ -797,7 +797,7 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {

)}

- Includes your MCP user ID {userId} so connector + Includes your username {username} so connector sessions stay isolated to your account.

diff --git a/packages/worker/src/app/authenticated-user.ts b/packages/worker/src/app/authenticated-user.ts index 316f95d782..fadefe4e12 100644 --- a/packages/worker/src/app/authenticated-user.ts +++ b/packages/worker/src/app/authenticated-user.ts @@ -59,6 +59,7 @@ export async function readAuthenticatedAppUser(request: Request, env: Env) { mcpUser: { userId: emailBasedUserId, email: userRecord.email, + username: userRecord.username, displayName, }, } satisfies AuthenticatedAppUser diff --git a/packages/worker/src/app/handlers/account-remote-connectors.node.test.ts b/packages/worker/src/app/handlers/account-remote-connectors.node.test.ts index f0d769cd4c..1e1f195964 100644 --- a/packages/worker/src/app/handlers/account-remote-connectors.node.test.ts +++ b/packages/worker/src/app/handlers/account-remote-connectors.node.test.ts @@ -4,12 +4,14 @@ 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', }, })), @@ -95,15 +97,14 @@ test('remote connector settings API lists settings with plaintext secrets', asyn expect(JSON.parse(text)).toEqual({ ok: true, email: 'user@example.com', - userId: 'stable-user-1', + username: 'test-user', connectorUrlOrigin: 'wss://example.com', connectors: [ { id: 'connector-1', kind: 'lights', instanceId: 'default', - connectorUrl: - 'wss://example.com/connectors/u/stable-user-1/lights/default', + connectorUrl: 'wss://example.com/@test-user/connectors/lights/default', enabled: true, attached: true, hasSharedSecret: true, @@ -145,7 +146,7 @@ test('remote connector settings API passes submitted secret to save service', as expect(await response.json()).toEqual({ ok: true, email: 'user@example.com', - userId: 'stable-user-1', + username: 'test-user', connectorUrlOrigin: 'wss://example.com', selectedConnectorId: 'connector-1', connectors: [ @@ -153,8 +154,7 @@ test('remote connector settings API passes submitted secret to save service', as id: 'connector-1', kind: 'lights', instanceId: 'default', - connectorUrl: - 'wss://example.com/connectors/u/stable-user-1/lights/default', + connectorUrl: 'wss://example.com/@test-user/connectors/lights/default', enabled: true, attached: true, hasSharedSecret: true, diff --git a/packages/worker/src/app/handlers/account-remote-connectors.ts b/packages/worker/src/app/handlers/account-remote-connectors.ts index 1bb201c5cb..a71597ffc2 100644 --- a/packages/worker/src/app/handlers/account-remote-connectors.ts +++ b/packages/worker/src/app/handlers/account-remote-connectors.ts @@ -32,7 +32,7 @@ type AccountRemoteConnectorListItem = { type AccountRemoteConnectorsPayload = { ok: true email: string - userId: string + username: string connectorUrlOrigin: string connectors: Array } @@ -191,17 +191,16 @@ async function buildPayload(input: { env: input.env, userId: input.user.mcpUser.userId, }) - const userId = input.user.mcpUser.userId return { ok: true, email: input.user.email, - userId, + username: input.user.username, connectorUrlOrigin: input.connectorUrlOrigin, connectors: connectors.map((connector) => ({ ...connector, connectorUrl: userScopedConnectorWebSocketUrl({ origin: input.connectorUrlOrigin, - userId, + username: input.user.username, kind: connector.kind, instanceId: connector.instanceId, }), diff --git a/packages/worker/src/app/handlers/package-app.node.test.ts b/packages/worker/src/app/handlers/package-app.node.test.ts index ac1f083380..0d12b8716e 100644 --- a/packages/worker/src/app/handlers/package-app.node.test.ts +++ b/packages/worker/src/app/handlers/package-app.node.test.ts @@ -12,11 +12,13 @@ const mockModule = vi.hoisted(() => ({ setContext: vi.fn(), }, readAuthenticatedAppUser: vi.fn(async () => ({ + username: 'test-user', email: 'user@example.com', displayName: 'User', mcpUser: { userId: 'user-1', email: 'user@example.com', + username: 'test-user', displayName: 'User', }, })), @@ -103,7 +105,9 @@ test('handlePackageAppRequest reports host setup failures with helpful responses resetMocks() const response = await handlePackageAppRequest( - new Request('https://example.com/packages/example/report?tab=errors'), + new Request( + 'https://example.com/@test-user/packages/example/report?tab=errors', + ), {} as Env, ) @@ -112,7 +116,7 @@ test('handlePackageAppRequest reports host setup failures with helpful responses const body = await response.text() expect(body).toContain('Package app could not be prepared') expect(body).toContain('@kody/example') - expect(body).toContain('/packages/example/report') + expect(body).toContain('/@test-user/packages/example/report') expect(mockModule.captureException).toHaveBeenCalledTimes(1) expect(mockModule.captureException).toHaveBeenCalledWith(expect.any(Error)) expect(mockModule.sentryScope.setLevel).toHaveBeenCalledWith('error') @@ -153,12 +157,12 @@ test('handlePackageAppRequest reports host setup failures with helpful responses packageName: '@kody/example', sourceId: 'source-1', forwardedPath: '/report', - hostPath: '/packages/example/report', + hostPath: '/@test-user/packages/example/report', }), ) const apiResponse = await handlePackageAppRequest( - new Request('https://example.com/packages/example/api/data', { + new Request('https://example.com/@test-user/packages/example/api/data', { headers: { accept: 'application/json' }, }), {} as Env, @@ -175,7 +179,7 @@ test('handlePackageAppRequest reports host setup failures with helpful responses name: '@kody/example', kody_id: 'example', }, - request_path: '/packages/example/api/data', + request_path: '/@test-user/packages/example/api/data', }) }) @@ -208,7 +212,7 @@ test('handlePackageAppRequest does not report package entrypoint failures to Kod }) const response = await handlePackageAppRequest( - new Request('https://example.com/packages/example'), + new Request('https://example.com/@test-user/packages/example'), {} as Env, ) @@ -223,33 +227,16 @@ test('handlePackageAppRequest does not report package entrypoint failures to Kod test('handlePackageAppRequest routes websocket package paths to realtime session manager', async () => { resetMocks() - const request = new Request('https://example.com/packages/example/ws/chat', { - headers: { - Upgrade: 'websocket', + const request = new Request( + 'https://example.com/@test-user/packages/example/ws/chat', + { + headers: { + Upgrade: 'websocket', + }, }, - }) - - const response = await handlePackageAppRequest(request, {} as Env) - - expect(response.status).toBe(200) - expect(mockModule.packageRealtimeConnect).toHaveBeenCalledTimes(1) - expect(mockModule.packageRealtimeConnect).toHaveBeenCalledWith( - request, - 'chat', ) - expect(mockModule.buildPackageAppWorker).not.toHaveBeenCalled() -}) -test('handlePackageAppRequest routes websocket paths to realtime session manager when explicitKodyId is provided', async () => { - resetMocks() - - const request = new Request('https://example.com/ws/chat', { - headers: { - Upgrade: 'websocket', - }, - }) - - const response = await handlePackageAppRequest(request, {} as Env, 'example') + const response = await handlePackageAppRequest(request, {} as Env) expect(response.status).toBe(200) expect(mockModule.packageRealtimeConnect).toHaveBeenCalledTimes(1) @@ -260,42 +247,14 @@ test('handlePackageAppRequest routes websocket paths to realtime session manager expect(mockModule.buildPackageAppWorker).not.toHaveBeenCalled() }) -test('handlePackageAppRequest preserves root forwarding for non-websocket explicitKodyId requests', async () => { +test('handlePackageAppRequest returns not found when the URL username does not match the signed-in user', async () => { resetMocks() - mockModule.loadPackageSourceBySourceId.mockResolvedValueOnce({ - source: { - published_commit: 'commit-1', - manifest_path: 'package.json', - source_root: '/', - }, - files: { - 'package.json': JSON.stringify({ - name: '@kody/example', - kody: { id: 'example', app: { entry: 'app.js' } }, - }), - 'app.js': 'export default {}', - }, - }) - const entrypointFetch = vi.fn(async (forwardedRequest: Request) => { - return Response.json({ pathname: new URL(forwardedRequest.url).pathname }) - }) - mockModule.buildPackageAppWorker.mockResolvedValueOnce({ - entrypointName: 'entry', - stub: { - getEntrypoint: () => ({ - fetch: entrypointFetch, - }), - }, - }) - const response = await handlePackageAppRequest( - new Request('https://example.com/custom/path'), + new Request('https://example.com/@other-user/packages/example'), {} as Env, - 'example', ) - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ pathname: '/' }) - expect(entrypointFetch).toHaveBeenCalledTimes(1) + expect(response.status).toBe(404) + expect(mockModule.getSavedPackageByKodyId).not.toHaveBeenCalled() }) diff --git a/packages/worker/src/app/handlers/package-app.ts b/packages/worker/src/app/handlers/package-app.ts index 441f5a07f4..d9954e43cb 100644 --- a/packages/worker/src/app/handlers/package-app.ts +++ b/packages/worker/src/app/handlers/package-app.ts @@ -4,6 +4,7 @@ import { createHtmlResponse } from 'remix/response/html' import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { redirectToLogin } from '#app/auth-redirect.ts' import { getAppBaseUrl } from '#app/app-base-url.ts' +import { getUsernameValidationError } from '#app/username.ts' import { getSavedPackageByKodyId } from '#worker/package-registry/repo.ts' import { loadPackageSourceBySourceId } from '#worker/package-registry/source.ts' import { @@ -15,21 +16,30 @@ import { wantsJson } from '#worker/utils.ts' function parsePackageAppPath(pathname: string) { const parts = pathname.split('/').filter(Boolean) - if (parts[0] !== 'packages') return null - const rawKodyId = parts[1]?.trim() + const rawUsername = parts[0]?.startsWith('@') ? parts[0].slice(1) : '' + if (!rawUsername || parts[1] !== 'packages') return null + const rawKodyId = parts[2]?.trim() if (!rawKodyId) return null + let username: string let kodyId: string try { + username = decodeURIComponent(rawUsername) kodyId = decodeURIComponent(rawKodyId) } catch { return null } + if (getUsernameValidationError(username)) return null return { + username, kodyId, - restPath: parts.length > 2 ? `/${parts.slice(2).join('/')}` : '/', + restPath: parts.length > 3 ? `/${parts.slice(3).join('/')}` : '/', } } +export function isPackageAppRequestPath(pathname: string) { + return parsePackageAppPath(pathname) !== null +} + function parsePackageRealtimePath(restPath: string) { const parts = restPath.split('/').filter(Boolean) if (parts[0] !== 'ws') return null @@ -225,27 +235,22 @@ function createPackageAppErrorResponse(input: { ) } -export async function handlePackageAppRequest( - request: Request, - env: Env, - explicitKodyId?: string | null, -) { +export async function handlePackageAppRequest(request: Request, env: Env) { const requestUrl = new URL(request.url) const packagePath = parsePackageAppPath(requestUrl.pathname) - const kodyId = explicitKodyId?.trim() || packagePath?.kodyId || null - if (!kodyId) { + if (!packagePath) { return new Response('Saved package app not found.', { status: 404 }) } - const packageRealtimeRestPath = - packagePath?.kodyId === kodyId - ? packagePath.restPath - : requestUrl.pathname || '/' - const forwardedPackageRestPath = - packagePath?.kodyId === kodyId ? packagePath.restPath : '/' + const { kodyId } = packagePath + const packageRealtimeRestPath = packagePath.restPath + const forwardedPackageRestPath = packagePath.restPath const user = await readAuthenticatedAppUser(request, env) if (!user) { return redirectToLogin(request) } + if (user.username !== packagePath.username) { + return new Response('Saved package app not found.', { status: 404 }) + } const savedPackage = await getSavedPackageByKodyId(env.APP_DB, { userId: user.mcpUser.userId, kodyId, @@ -302,6 +307,7 @@ export async function handlePackageAppRequest( userId: user.mcpUser.userId, email: user.email, displayName: user.displayName, + username: user.username, }, packageId: savedPackage.id, }) diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index 4acd8551f5..dff3b3f897 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -35,7 +35,7 @@ export function createAppRouter(appEnv: AppEnv) { const router = createRouter({ middleware: [], async defaultHandler() { - return render(Layout({})) + return render(Layout({}), { status: 404 }) }, }) diff --git a/packages/worker/src/app/user-lookup.ts b/packages/worker/src/app/user-lookup.ts new file mode 100644 index 0000000000..abf70c7145 --- /dev/null +++ b/packages/worker/src/app/user-lookup.ts @@ -0,0 +1,60 @@ +import { getUsernameValidationError } from '#app/username.ts' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' + +export type PublicUserIdentity = { + userId: number + username: string + email: string + mcpUserId: string +} + +export async function findPublicUserIdentityByUsername(input: { + db: D1Database + username: string +}): Promise { + const username = input.username.trim() + if (getUsernameValidationError(username)) return null + + const userRecord = await input.db + .prepare( + `SELECT id, username, email + FROM users + WHERE username = ?`, + ) + .bind(username) + .first<{ id: number; username: string; email: string }>() + if (!userRecord) return null + + return { + userId: userRecord.id, + username: userRecord.username, + email: userRecord.email, + mcpUserId: await createStableUserIdFromEmail(userRecord.email), + } +} + +export async function resolvePublicUsername(input: { + db: D1Database + username?: string | null + email?: string | null +}): Promise { + const username = input.username?.trim() + if (username && !getUsernameValidationError(username)) return username + + const email = input.email?.trim().toLowerCase() + if (!email) return null + + const userRecord = await input.db + .prepare( + `SELECT username + FROM users + WHERE email = ?`, + ) + .bind(email) + .first<{ username: string | null }>() + + const resolvedUsername = userRecord?.username?.trim() + return resolvedUsername && !getUsernameValidationError(resolvedUsername) + ? resolvedUsername + : null +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index ca67da7cd8..63b0884002 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -45,9 +45,13 @@ import { parseUserScopedConnectorRoutePath, userScopedConnectorSessionKey, } from './remote-connector/connector-session-key.ts' -import { handlePackageAppRequest } from '#app/handlers/package-app.ts' +import { + handlePackageAppRequest, + isPackageAppRequestPath, +} from '#app/handlers/package-app.ts' import { PackageAppRuntimeBridge } from '#worker/package-runtime/package-app.ts' import { handleInboundEmail } from '#worker/email/inbound.ts' +import { findPublicUserIdentityByUsername } from '#app/user-lookup.ts' export { RepoSession, @@ -64,6 +68,55 @@ export { const claudeWidgetDomainSuffix = '.claudemcpcontent.com' +function isNamespacedPackageInvocationEndpointPath(pathname: string) { + const parts = pathname.split('/').filter(Boolean) + return ( + parts[0]?.startsWith('@') === true && + parts[1] === 'api' && + parts[2] === 'package-invocations' + ) +} + +function isNamespacedAppEndpointPath(pathname: string) { + const parts = pathname.split('/').filter(Boolean) + return ( + parts[0]?.startsWith('@') === true && + (parts[1] === 'packages' || parts[1] === 'connectors') + ) +} + +async function handleUserScopedConnectorRequest(request: Request, env: Env) { + const url = new URL(request.url) + const userScopedConnectorRoute = parseUserScopedConnectorRoutePath( + url.pathname, + ) + if (!userScopedConnectorRoute) return null + if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') { + return new Response('Not Found', { status: 404 }) + } + const routeUser = await findPublicUserIdentityByUsername({ + db: env.APP_DB, + username: userScopedConnectorRoute.username, + }) + if (!routeUser) { + return new Response('Not Found', { status: 404 }) + } + const sessionKey = userScopedConnectorSessionKey({ + userId: routeUser.mcpUserId, + kind: userScopedConnectorRoute.kind, + instanceId: userScopedConnectorRoute.instanceId, + }) + const stub = env.REMOTE_CONNECTOR_SESSION.get( + env.REMOTE_CONNECTOR_SESSION.idFromName(sessionKey), + ) + const forwardUrl = new URL(request.url) + forwardUrl.pathname = userScopedConnectorRoute.rest || '/' + const forwardRequest = new Request(forwardUrl.toString(), request) + forwardRequest.headers.set('X-Kody-Connector-Session-Key', sessionKey) + forwardRequest.headers.set('X-Kody-Connector-User-Id', routeUser.mcpUserId) + return stub.fetch(forwardRequest) +} + function isAllowedGeneratedUiOrigin(origin: string, requestOrigin: string) { if (origin === requestOrigin) { return true @@ -186,34 +239,15 @@ const appHandler = withCors({ return handleGeneratedUiApiRequest(request, env) } - if (url.pathname.startsWith('/packages/')) { + if (isPackageAppRequestPath(url.pathname)) { return handlePackageAppRequest(request, env) } - const userScopedConnectorRoute = parseUserScopedConnectorRoutePath( - url.pathname, - ) - if (userScopedConnectorRoute) { - if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') { - return new Response('Not Found', { status: 404 }) - } - const sessionKey = userScopedConnectorSessionKey({ - userId: userScopedConnectorRoute.userId, - kind: userScopedConnectorRoute.kind, - instanceId: userScopedConnectorRoute.instanceId, - }) - const stub = env.REMOTE_CONNECTOR_SESSION.get( - env.REMOTE_CONNECTOR_SESSION.idFromName(sessionKey), - ) - const forwardUrl = new URL(request.url) - forwardUrl.pathname = userScopedConnectorRoute.rest || '/' - const forwardRequest = new Request(forwardUrl.toString(), request) - forwardRequest.headers.set('X-Kody-Connector-Session-Key', sessionKey) - forwardRequest.headers.set( - 'X-Kody-Connector-User-Id', - userScopedConnectorRoute.userId, - ) - return stub.fetch(forwardRequest) + if ( + isNamespacedAppEndpointPath(url.pathname) || + isNamespacedPackageInvocationEndpointPath(url.pathname) + ) { + return new Response('Not Found', { status: 404 }) } if (url.pathname.startsWith('/connectors/')) { @@ -307,12 +341,22 @@ function addOAuthDiscoveryCorsHeaders( } const workerHandler = { - fetch(request: Request, env: Env, ctx: ExecutionContext) { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url) if (isPackageInvocationApiRequest(url.pathname)) { return handlePackageInvocationApiRequest(request, env, ctx) } + const connectorResponse = await handleUserScopedConnectorRequest( + request, + env, + ) + if (connectorResponse) return connectorResponse + + if (isNamespacedPackageInvocationEndpointPath(url.pathname)) { + return new Response('Not Found', { status: 404 }) + } + // OAuthProvider serves this URL first and defaults `resource` to the origin only. // MCP clients must use `/mcp` as the resource (RFC 8707) to match our // token audience; otherwise authorize stores origin but the token request sends diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index cf572060df..2709d1d955 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -662,6 +662,7 @@ function createPackageJobCallerContext(input: { user: { userId: input.userId, email: '', + username: undefined, displayName: `package:${input.packageId}`, }, storageContext: { diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts index bf0eec2a6e..bacff8c574 100644 --- a/packages/worker/src/mcp/generated-ui-api.ts +++ b/packages/worker/src/mcp/generated-ui-api.ts @@ -157,6 +157,7 @@ function createGeneratedUiExecuteHandler(env: Env) { user: { userId: context.user.userId, email: context.user.email, + username: undefined, displayName: '', }, storageContext: { diff --git a/packages/worker/src/mcp/tools/open-generated-ui.node.test.ts b/packages/worker/src/mcp/tools/open-generated-ui.node.test.ts index ca3c901490..85b91e2768 100644 --- a/packages/worker/src/mcp/tools/open-generated-ui.node.test.ts +++ b/packages/worker/src/mcp/tools/open-generated-ui.node.test.ts @@ -33,19 +33,24 @@ vi.mock('#mcp/generated-ui-app-session.ts', () => ({ const { registerOpenGeneratedUiTool } = await import('./open-generated-ui.ts') -async function getOpenGeneratedUiHandler() { +async function getOpenGeneratedUiHandler(options?: { + username?: string + appDb?: D1Database +}) { mockModule.registerAppTool.mockClear() await registerOpenGeneratedUiTool({ server: {} as never, getEnv: vi.fn(() => ({ - APP_DB: {} as D1Database, + APP_DB: options?.appDb ?? ({} as D1Database), })), getCallerContext: vi.fn(() => ({ baseUrl: 'https://example.com', user: { userId: 'user-123', email: 'user@example.com', + username: options?.username, + displayName: 'test-user', }, })), requireDomain: vi.fn(() => 'https://example.com'), @@ -63,7 +68,7 @@ async function getOpenGeneratedUiHandler() { } test('open_generated_ui reopens saved package apps by kody_id', async () => { - const handler = await getOpenGeneratedUiHandler() + const handler = await getOpenGeneratedUiHandler({ username: 'test-user' }) mockModule.getSavedPackageByKodyId.mockResolvedValueOnce({ id: 'package-123', userId: 'user-123', @@ -91,6 +96,39 @@ test('open_generated_ui reopens saved package apps by kody_id', async () => { ) expect(response.structuredContent).toMatchObject({ appId: 'package-123', - hostedUrl: 'https://example.com/packages/observed-package', + hostedUrl: 'https://example.com/@test-user/packages/observed-package', + }) +}) + +test('open_generated_ui resolves username from email for older caller contexts', async () => { + const handler = await getOpenGeneratedUiHandler({ + appDb: { + prepare: () => ({ + bind: () => ({ + first: async () => ({ username: 'resolved-user' }), + }), + }), + } as unknown as D1Database, + }) + mockModule.getSavedPackageByKodyId.mockResolvedValueOnce({ + id: 'package-123', + userId: 'user-123', + name: '@kody/observed', + kodyId: 'observed-package', + description: 'Observed package app', + tags: ['ui'], + searchText: null, + sourceId: 'source-123', + hasApp: true, + createdAt: '2026-04-21T00:00:00.000Z', + updatedAt: '2026-04-21T00:00:00.000Z', + }) + + const response = await handler({ + kody_id: 'observed-package', + }) + + expect(response.structuredContent).toMatchObject({ + hostedUrl: 'https://example.com/@resolved-user/packages/observed-package', }) }) diff --git a/packages/worker/src/mcp/tools/open-generated-ui.ts b/packages/worker/src/mcp/tools/open-generated-ui.ts index 1c802beaa7..230127bec3 100644 --- a/packages/worker/src/mcp/tools/open-generated-ui.ts +++ b/packages/worker/src/mcp/tools/open-generated-ui.ts @@ -5,6 +5,11 @@ import { generatedUiRuntimeResourceUri } from '#mcp/apps/generated-ui-runtime-ht import { createGeneratedUiAppSession } from '#mcp/generated-ui-app-session.ts' import { getSavedPackageByKodyId } from '#worker/package-registry/repo.ts' import { type McpRegistrationAgent } from '#mcp/mcp-registration-agent.ts' +import { + buildPackageAppUrl, + requireUsernameForPublicUrl, +} from '@kody-internal/shared/public-urls.ts' +import { resolvePublicUsername } from '#app/user-lookup.ts' import { conversationIdInputField, memoryContextInputField, @@ -118,8 +123,17 @@ export async function registerOpenGeneratedUiTool(agent: McpRegistrationAgent) { ) } } + const username = await resolvePublicUsername({ + db: agent.getEnv().APP_DB, + username: callerContext.user?.username ?? null, + email: callerContext.user?.email ?? null, + }) const hostedUrl = savedPackage - ? `${agent.requireDomain()}/packages/${encodeURIComponent(savedPackage.kodyId)}` + ? buildPackageAppUrl({ + origin: agent.requireDomain(), + username: requireUsernameForPublicUrl(username), + kodyId: savedPackage.kodyId, + }) : null const inlineSourceCode = savedPackage ? [ diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index 71761c6c18..b9763d7a17 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -322,7 +322,7 @@ test('entity detail formatting includes package app, export, and job metadata', id: 'observed-package', title: '@kody/observed-package', description: 'Observed package with an app surface.', - hostedUrl: 'http://localhost/packages/observed-package', + hostedUrl: 'http://localhost/@test-user/packages/observed-package', record: { id: 'package-123', userId: 'user-123', @@ -387,7 +387,7 @@ export declare function fetch(request: Request): Promise entityRef: 'observed-package:package', usage: 'open_generated_ui({ kody_id: "observed-package" })', hasApp: true, - hostedUrl: 'http://localhost/packages/observed-package', + hostedUrl: 'http://localhost/@test-user/packages/observed-package', appEntry: './src/app.ts', }) expect(packageDetail.structured).toMatchObject({ @@ -530,6 +530,7 @@ export declare function launch(input: LaunchCursorCloudAgentInput): Promise { const [packageMatch] = toSlimStructuredMatches({ baseUrl: 'http://localhost', + username: 'test-user', matches: [ { type: 'package', @@ -563,7 +564,7 @@ test('package search formatting keeps runnable package actions in structured out openGeneratedUiUsage: 'open_generated_ui({ kody_id: "spotify-playback" })', tags: ['spotify', 'playback'], hasApp: true, - hostedUrl: 'http://localhost/packages/spotify-playback', + hostedUrl: 'http://localhost/@test-user/packages/spotify-playback', readmeSnippet: { path: 'README.md', snippet: @@ -574,6 +575,32 @@ test('package search formatting keeps runnable package actions in structured out expect(packageMatch?.nextStep).toEqual(expect.any(String)) }) +test('package search formatting omits hosted URLs when username is unavailable', () => { + const [packageMatch] = toSlimStructuredMatches({ + baseUrl: 'http://localhost', + matches: [ + { + type: 'package', + packageId: 'package-123', + kodyId: 'spotify-playback', + name: '@kody/spotify-playback', + title: '@kody/spotify-playback', + description: 'Saved package for Spotify playback controls.', + tags: ['spotify', 'playback'], + hasApp: true, + readmeSnippet: null, + }, + ], + }) + + expect(packageMatch).toMatchObject({ + type: 'package', + hasApp: true, + hostedUrl: null, + openGeneratedUiUsage: 'open_generated_ui({ kody_id: "spotify-playback" })', + }) +}) + test('package search formatting surfaces matched action recipes compactly', () => { const markdown = formatSearchMarkdown({ matches: [ @@ -609,6 +636,7 @@ test('package search formatting surfaces matched action recipes compactly', () = }) const [packageMatch] = toSlimStructuredMatches({ baseUrl: 'http://localhost', + username: 'test-user', matches: [ { type: 'package', diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index 41bbb616b6..77b6b377aa 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -18,6 +18,7 @@ import { escapeMarkdownText, formatMarkdownInlineCode, } from './markdown-safety.ts' +import { buildPackageAppUrl } from '@kody-internal/shared/public-urls.ts' export type SearchEntityType = | 'capability' @@ -405,8 +406,12 @@ export type SearchMatch = type: 'retriever_result' }) -function buildPackageHostedUrl(baseUrl: string, kodyId: string) { - return `${baseUrl.replace(/\/+$/, '')}/packages/${encodeURIComponent(kodyId)}` +function buildPackageHostedUrl( + baseUrl: string, + username: string, + kodyId: string, +) { + return buildPackageAppUrl({ origin: baseUrl, username, kodyId }) } function buildEntityRef(id: string, type: SearchEntityType) { @@ -612,6 +617,7 @@ function formatMatchListItem(match: SearchMatch, index: number) { export function toSlimStructuredMatches(input: { matches: Array baseUrl: string + username?: string | null }): Array { return input.matches.map((match) => { if (match.type === 'capability') { @@ -682,9 +688,10 @@ export function toSlimStructuredMatches(input: { openGeneratedUiUsage, tags: match.tags, hasApp: match.hasApp, - hostedUrl: match.hasApp - ? buildPackageHostedUrl(input.baseUrl, match.kodyId) - : null, + hostedUrl: + match.hasApp && input.username + ? buildPackageHostedUrl(input.baseUrl, input.username, match.kodyId) + : null, readmeSnippet: match.readmeSnippet ? { path: match.readmeSnippet.path, diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index be6c04944c..b2314bedd6 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -46,8 +46,10 @@ import { type RemoteConnectorStatus, } from '#worker/remote-connector/status.ts' import { type McpCallerContext } from '@kody-internal/shared/chat.ts' +import { buildPackageAppUrl } from '@kody-internal/shared/public-urls.ts' import { normalizeRemoteConnectorRefs } from '@kody-internal/shared/remote-connectors.ts' import { type PackageRetrieverSurfaceResult } from '#worker/package-retrievers/types.ts' +import { resolvePublicUsername } from '#app/user-lookup.ts' import { callerContextFields, errorFields, @@ -1629,6 +1631,7 @@ async function resolveEntityDetail(input: { agent: McpRegistrationAgent callerContext: ReturnType userId: string | null + username: string | null entity: string searchRows: SearchRowsAndRegistry }) { @@ -1673,9 +1676,14 @@ async function resolveEntityDetail(input: { record, manifest: loaded.manifest, files: loaded.files, - hostedUrl: record.hasApp - ? `${input.callerContext.baseUrl}/packages/${encodeURIComponent(record.kodyId)}` - : null, + hostedUrl: + record.hasApp && input.username + ? buildPackageAppUrl({ + origin: input.callerContext.baseUrl, + username: input.username, + kodyId: record.kodyId, + }) + : null, } } @@ -1820,9 +1828,15 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { const maxResponseSize = args.maxResponseSize ?? defaultMaxResponseSize let warnings: Array = [] let remoteConnectorDownStatuses: Array = [] + let username: string | null = null const searchSpan = async () => { const query = args.query?.trim() ?? '' + username = await resolvePublicUsername({ + db: agent.getEnv().APP_DB, + username: callerContext.user?.username ?? null, + email: callerContext.user?.email ?? null, + }) const retrieverRunPromise = userId && query ? (async () => { @@ -1877,6 +1891,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { agent, callerContext, userId, + username, entity: args.entity, searchRows, }), @@ -2074,6 +2089,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { matches: toSlimStructuredMatches({ matches: trimmedPayload.matches, baseUrl, + username, }), } const timing = finishToolTiming(timingStart) diff --git a/packages/worker/src/oauth-handlers.ts b/packages/worker/src/oauth-handlers.ts index 0428e773cf..3f2af878ad 100644 --- a/packages/worker/src/oauth-handlers.ts +++ b/packages/worker/src/oauth-handlers.ts @@ -17,6 +17,7 @@ import { createDb, usersTable } from './db.ts' import { wantsJson } from './utils.ts' import { verifyPassword } from '@kody-internal/shared/password-hash.ts' import { invalidClientIdMismatchMessage } from '@kody-internal/shared/oauth-messages.ts' +import { getUsernameValidationError } from '#app/username.ts' export const oauthPaths = { authorize: '/oauth/authorize', @@ -32,6 +33,7 @@ export const oauthScopes: Array = ['profile', 'email'] type OAuthProps = { userId: string email: string + username: string displayName: string } @@ -43,6 +45,12 @@ type OAuthContext = ExecutionContext & { props?: OAuthProps } +function getValidOAuthUsername(value: unknown) { + return typeof value === 'string' && !getUsernameValidationError(value.trim()) + ? value.trim() + : null +} + type OAuthClientResetVerification = { clientId: string reason: 'invalid-client-id-mismatch' @@ -628,6 +636,7 @@ export async function handleAuthorizeRequest( } let approvedEmail = '' + let approvedUsername = '' if (hasFormCredentials) { const db = createDb(env.APP_DB) const userRecord = await db.findOne(usersTable, { @@ -652,15 +661,59 @@ export async function handleAuthorizeRequest( }) return respondAuthorizeError(request, 'Invalid email or password.') } + const username = getValidOAuthUsername(userRecord.username) + if (!username) { + void logAuditEvent({ + category: 'oauth', + action: 'authorize', + result: 'failure', + email: normalizedEmail, + ip: requestIp, + clientId: authRequest.clientId, + reason: 'username_missing', + }) + return respondAuthorizeError(request, 'Username is required.', 401) + } approvedEmail = normalizedEmail + approvedUsername = username } else if (sessionEmail) { + const db = createDb(env.APP_DB) + const userRecord = await db.findOne(usersTable, { + where: { email: sessionEmail }, + }) + if (!userRecord) { + void logAuditEvent({ + category: 'oauth', + action: 'authorize', + result: 'failure', + email: sessionEmail, + ip: requestIp, + clientId: authRequest.clientId, + reason: 'session_user_not_found', + }) + return respondAuthorizeError(request, 'Signed-in user not found.', 401) + } + const username = getValidOAuthUsername(userRecord.username) + if (!username) { + void logAuditEvent({ + category: 'oauth', + action: 'authorize', + result: 'failure', + email: sessionEmail, + ip: requestIp, + clientId: authRequest.clientId, + reason: 'username_missing', + }) + return respondAuthorizeError(request, 'Username is required.', 401) + } approvedEmail = sessionEmail + approvedUsername = username } const resolvedScopes = resolveScopes(authRequest.scope) if (Array.isArray(resolvedScopes)) { const userId = await createStableUserIdFromEmail(approvedEmail) - const displayName = approvedEmail.split('@')[0] || 'user' + const displayName = approvedUsername const { redirectTo } = await helpers.completeAuthorization({ request: authRequest, userId, @@ -672,6 +725,7 @@ export async function handleAuthorizeRequest( props: { userId, email: approvedEmail, + username: approvedUsername, displayName, }, }) diff --git a/packages/worker/src/oauth-handlers.workers.test.ts b/packages/worker/src/oauth-handlers.workers.test.ts index c599128e27..cac5ac8dbe 100644 --- a/packages/worker/src/oauth-handlers.workers.test.ts +++ b/packages/worker/src/oauth-handlers.workers.test.ts @@ -61,12 +61,24 @@ async function createDatabase(password: string) { return { async all() { return { - results: [{ id: 1, password_hash: passwordHash }], + results: [ + { + id: 1, + username: 'test-user', + email: 'user@example.com', + password_hash: passwordHash, + }, + ], meta: { changes: 0, last_row_id: 0 }, } }, async first() { - return { id: 1, password_hash: passwordHash } + return { + id: 1, + username: 'test-user', + email: 'user@example.com', + password_hash: passwordHash, + } }, async run() { return { meta: { changes: 1, last_row_id: 1 } } @@ -243,7 +255,7 @@ test('authorize allows approval with an existing session', async () => { { decision: 'approve' }, { Accept: 'application/json', Cookie: cookie }, ), - createEnv(helpers), + createEnv(helpers, await createDatabase('password123')), ) expect(response.status).toBe(200) diff --git a/packages/worker/src/package-invocations/http.ts b/packages/worker/src/package-invocations/http.ts index 12259c5439..1c56cf4c25 100644 --- a/packages/worker/src/package-invocations/http.ts +++ b/packages/worker/src/package-invocations/http.ts @@ -1,5 +1,6 @@ import { getRequestIp, logAuditEvent } from '#app/audit-log.ts' import { getAppBaseUrl } from '#app/app-base-url.ts' +import { findPublicUserIdentityByUsername } from '#app/user-lookup.ts' import { invokePackageExport, type PackageInvocationTokenScope, @@ -10,8 +11,6 @@ import { updatePackageInvocationTokenLastUsed, } from './repo.ts' -const packageInvocationPathPrefix = '/api/package-invocations/' - type PackageInvocationRequestBody = { params?: Record idempotencyKey?: string @@ -82,20 +81,23 @@ function decodePathComponent(value: string) { } function parsePackageInvocationPath(pathname: string) { - if (!pathname.startsWith(packageInvocationPathPrefix)) { - return null - } - const rest = pathname.slice(packageInvocationPathPrefix.length) - const parts = rest.split('/') - if (parts.length < 2) { + const parts = pathname.split('/').filter(Boolean) + if ( + parts.length < 5 || + !parts[0]?.startsWith('@') || + parts[0].length <= 1 || + parts[1] !== 'api' || + parts[2] !== 'package-invocations' + ) { return null } - const packageIdOrKodyId = decodePathComponent(parts[0] ?? '') - const exportName = decodePathComponent(parts.slice(1).join('/')) - if (!packageIdOrKodyId || !exportName) { + const username = decodePathComponent(parts[0].slice(1)) + const kodyId = decodePathComponent(parts[3] ?? '') + const exportName = decodePathComponent(parts.slice(4).join('/')) + if (!username || !kodyId || !exportName) { return null } - return { packageIdOrKodyId, exportName } + return { username, kodyId, exportName } } async function resolveTokenScope(input: { @@ -168,7 +170,7 @@ async function readRequestBody( } export function isPackageInvocationApiRequest(pathname: string) { - return pathname.startsWith(packageInvocationPathPrefix) + return parsePackageInvocationPath(pathname) !== null } export async function handlePackageInvocationApiRequest( @@ -220,6 +222,22 @@ export async function handlePackageInvocationApiRequest( }) return unauthorizedResponse('Invalid package invocation token.') } + const routeUser = await findPublicUserIdentityByUsername({ + db: env.APP_DB, + username: route.username, + }) + if (!routeUser || routeUser.mcpUserId !== tokenScope.userId) { + logPackageInvocationAudit(ctx, { + category: 'oauth', + action: 'package_invoke', + result: 'failure', + email: tokenScope.email, + ip: requestIp, + path: new URL(request.url).pathname, + reason: 'username_token_mismatch', + }) + return notFoundResponse() + } const parsedBody = await readRequestBody(request) if (!parsedBody.ok) { return parsedBody.response @@ -289,7 +307,7 @@ export async function handlePackageInvocationApiRequest( }), token: tokenScope, request: { - packageIdOrKodyId: route.packageIdOrKodyId, + packageIdOrKodyId: route.kodyId, exportName: route.exportName, params: body.params, idempotencyKey: body.idempotencyKey, diff --git a/packages/worker/src/package-invocations/http.workers.test.ts b/packages/worker/src/package-invocations/http.workers.test.ts index 0387dd0667..0da6d87d05 100644 --- a/packages/worker/src/package-invocations/http.workers.test.ts +++ b/packages/worker/src/package-invocations/http.workers.test.ts @@ -5,6 +5,7 @@ import { isPackageInvocationApiRequest, } from './http.ts' import { hashPackageInvocationBearerToken } from './repo.ts' +import { createStableUserIdFromEmail } from '#worker/user-id.ts' const invocationMockModule = vi.hoisted(() => ({ invokePackageExport: vi.fn(), @@ -33,10 +34,11 @@ async function createEnv( touchError?: Error } = {}, ) { + const tokenUserId = await createStableUserIdFromEmail('me@example.com') const tokenRows = [ { id: 'discord-gateway', - user_id: 'user-123', + user_id: tokenUserId, token_hash: await hashPackageInvocationBearerToken('private-token-123'), name: 'Discord gateway', email: 'me@example.com', @@ -63,6 +65,21 @@ async function createEnv( bind(...params: Array) { return { async first>() { + if (query.includes('FROM users')) { + const username = String(params[0] ?? '') + return ( + username === 'my-user' + ? { + id: 1, + username: 'my-user', + email: 'me@example.com', + password_hash: 'hash', + created_at: '2026-04-27T00:00:00.000Z', + updated_at: '2026-04-27T00:00:00.000Z', + } + : null + ) as T | null + } if (query.includes('FROM package_invocation_tokens')) { const tokenHash = String(params[0] ?? '') return (tokenRows.find( @@ -137,7 +154,7 @@ function createContext() { test('isPackageInvocationApiRequest matches the external package invocation route', () => { expect( isPackageInvocationApiRequest( - '/api/package-invocations/discord-gateway/dispatch-message-created', + '/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', ), ).toBe(true) expect(isPackageInvocationApiRequest('/api/me')).toBe(false) @@ -146,7 +163,7 @@ test('isPackageInvocationApiRequest matches the external package invocation rout test('package invocation API returns 401 when bearer token is missing', async () => { const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -173,7 +190,7 @@ test('package invocation API returns 401 when bearer token is missing', async () test('package invocation API returns 401 for invalid private tokens', async () => { const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { @@ -200,7 +217,7 @@ test('package invocation API returns 401 for invalid private tokens', async () = test('package invocation API fails closed when token touch loses revocation race', async () => { const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { @@ -228,7 +245,7 @@ test('package invocation API fails closed when token touch loses revocation race test('package invocation API fails closed when token scope JSON is malformed', async () => { const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { @@ -260,7 +277,7 @@ test('package invocation API fails closed when token scope JSON is malformed', a test('package invocation API validates the JSON body shape', async () => { const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { @@ -300,9 +317,10 @@ test('package invocation API invokes the package export with the scoped token co }) const ctx = createContext() + const expectedUserId = await createStableUserIdFromEmail('me@example.com') const response = await handlePackageInvocationApiRequest( new Request( - 'https://example.com/api/package-invocations/discord-gateway/dispatch-message-created', + 'https://example.com/@my-user/api/package-invocations/discord-gateway/dispatch-message-created', { method: 'POST', headers: { @@ -326,7 +344,7 @@ test('package invocation API invokes the package export with the scoped token co baseUrl: 'https://example.com', token: { tokenId: 'discord-gateway', - userId: 'user-123', + userId: expectedUserId, email: 'me@example.com', displayName: 'me', packageIds: [], diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index 64abd56a47..fbe9efda4b 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -1041,6 +1041,7 @@ async function invokeSavedPackageModule(input: { user: { userId: input.actor.userId, email: input.actor.email, + username: undefined, displayName: input.actor.displayName, }, storageContext: { diff --git a/packages/worker/src/package-retrievers/service.ts b/packages/worker/src/package-retrievers/service.ts index e9e57be891..3ad1bcd669 100644 --- a/packages/worker/src/package-retrievers/service.ts +++ b/packages/worker/src/package-retrievers/service.ts @@ -128,6 +128,7 @@ async function invokeRetriever(input: { user: { userId: input.userId, email: '', + username: undefined, displayName: '', }, storageContext: { diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index d39e01153f..1519fa9034 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -628,6 +628,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< user: { userId: this.ctx.props.userId, email: this.ctx.props.email, + username: undefined, displayName: this.ctx.props.displayName, }, storageContext: { @@ -1193,6 +1194,7 @@ export async function createPackageAppCallerContext(input: { user: { userId: string email: string + username?: string displayName?: string } packageId: string @@ -1202,6 +1204,7 @@ export async function createPackageAppCallerContext(input: { user: { userId: input.user.userId, email: input.user.email, + username: input.user.username, displayName: input.user.displayName ?? `package:${input.packageId}`, }, storageContext: { diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 5ee870d053..e5007841c6 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -526,6 +526,7 @@ class PackageServiceInstanceBase extends DurableObject { user: { userId: binding.userId, email: '', + username: undefined, displayName: `package:${binding.packageId}`, }, storageContext: { diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index 0105cdd850..e9e8b3451a 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -1041,6 +1041,7 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint< user: { userId: payload.userId, email: '', + username: undefined, displayName: `workflow:${payload.workflowName}`, }, remoteConnectors, diff --git a/packages/worker/src/package-runtime/realtime-session.ts b/packages/worker/src/package-runtime/realtime-session.ts index 57211b4987..c2f13bc683 100644 --- a/packages/worker/src/package-runtime/realtime-session.ts +++ b/packages/worker/src/package-runtime/realtime-session.ts @@ -314,6 +314,7 @@ async function resolvePackageAppWorker(input: { user: { userId: input.binding.userId, email: '', + username: undefined, displayName: `package:${input.binding.packageId}`, }, storageContext: { diff --git a/packages/worker/src/remote-connector/connector-session-key.node.test.ts b/packages/worker/src/remote-connector/connector-session-key.node.test.ts index 06d12b8af3..483589c8b1 100644 --- a/packages/worker/src/remote-connector/connector-session-key.node.test.ts +++ b/packages/worker/src/remote-connector/connector-session-key.node.test.ts @@ -37,38 +37,38 @@ test('userScopedConnectorSessionKey unambiguously encodes "/" in any segment', ( expect(collidingA).not.toBe(collidingB) }) -test('userScopedConnectorIngressPath builds /connectors/u/... routes', () => { +test('userScopedConnectorIngressPath builds username-scoped connector routes', () => { expect( userScopedConnectorIngressPath({ - userId: 'user-aaa', + username: 'user-aaa', kind: 'lights', instanceId: 'living-room', }), - ).toBe('/connectors/u/user-aaa/lights/living-room') + ).toBe('/@user-aaa/connectors/lights/living-room') expect( userScopedConnectorIngressPath({ - userId: 'with space', + username: 'with space', kind: 'CUSTOM', instanceId: 'a b', }), - ).toBe('/connectors/u/with%20space/custom/a%20b') + ).toBe('/@with%20space/connectors/custom/a%20b') }) -test('parseUserScopedConnectorRoutePath extracts userId, kind, and instanceId', () => { +test('parseUserScopedConnectorRoutePath extracts username, kind, and instanceId', () => { expect( parseUserScopedConnectorRoutePath( - '/connectors/u/user-aaa/lights/default/snapshot', + '/@user-aaa/connectors/lights/default/snapshot', ), ).toEqual({ - userId: 'user-aaa', + username: 'user-aaa', kind: 'lights', instanceId: 'default', rest: '/snapshot', }) expect( - parseUserScopedConnectorRoutePath('/connectors/u/user-bbb/custom/abc'), + parseUserScopedConnectorRoutePath('/@user-bbb/connectors/custom/abc'), ).toEqual({ - userId: 'user-bbb', + username: 'user-bbb', kind: 'custom', instanceId: 'abc', rest: '', @@ -77,6 +77,6 @@ test('parseUserScopedConnectorRoutePath extracts userId, kind, and instanceId', parseUserScopedConnectorRoutePath('/connectors/lights/default'), ).toBeNull() expect( - parseUserScopedConnectorRoutePath('/connectors/u/user-aaa/lights'), + parseUserScopedConnectorRoutePath('/@user-aaa/connectors/lights'), ).toBeNull() }) diff --git a/packages/worker/src/remote-connector/connector-session-key.ts b/packages/worker/src/remote-connector/connector-session-key.ts index ba102f30b1..6544cfa464 100644 --- a/packages/worker/src/remote-connector/connector-session-key.ts +++ b/packages/worker/src/remote-connector/connector-session-key.ts @@ -6,7 +6,7 @@ import { export { userScopedConnectorIngressPath } from '@kody-internal/shared/remote-connectors.ts' export type UserScopedConnectorRouteMatch = { - userId: string + username: string kind: string instanceId: string rest: string @@ -43,23 +43,23 @@ export function parseUserScopedConnectorRoutePath( } } if ( - parts.length >= 5 && - parts[0] === 'connectors' && - parts[1] === 'u' && + parts.length >= 4 && + parts[0]?.startsWith('@') && + parts[0].length > 1 && + parts[1] === 'connectors' && parts[2] && - parts[3] && - parts[4] + parts[3] ) { - const decodedUserId = decodeSegment(parts[2]) - const decodedKind = decodeSegment(parts[3]) - const decodedInstanceId = decodeSegment(parts[4]) - if (!decodedUserId || !decodedKind || !decodedInstanceId) return null - const userId = decodedUserId.trim() + const decodedUsername = decodeSegment(parts[0].slice(1)) + const decodedKind = decodeSegment(parts[2]) + const decodedInstanceId = decodeSegment(parts[3]) + if (!decodedUsername || !decodedKind || !decodedInstanceId) return null + const username = decodedUsername.trim() const kind = decodedKind.trim().toLowerCase() const instanceId = decodedInstanceId.trim() - if (!userId || !kind || !instanceId) return null - const rest = parts.length > 5 ? `/${parts.slice(5).join('/')}` : '' - return { userId, kind, instanceId, rest } + if (!username || !kind || !instanceId) return null + const rest = parts.length > 4 ? `/${parts.slice(4).join('/')}` : '' + return { username, kind, instanceId, rest } } return null } diff --git a/packages/worker/src/remote-connector/remote-connectors-shared.node.test.ts b/packages/worker/src/remote-connector/remote-connectors-shared.node.test.ts index 7bfae0c880..cab2559f6e 100644 --- a/packages/worker/src/remote-connector/remote-connectors-shared.node.test.ts +++ b/packages/worker/src/remote-connector/remote-connectors-shared.node.test.ts @@ -34,13 +34,13 @@ test('normalizeRemoteConnectorRefs preserves an empty connector list', () => { ).toEqual([]) }) -test('userScopedConnectorWebSocketUrl builds encoded user-scoped connector URLs', () => { +test('userScopedConnectorWebSocketUrl builds encoded username-scoped connector URLs', () => { expect( userScopedConnectorWebSocketUrl({ origin: 'wss://kody.example.com/', - userId: 'user aaa', + username: 'user-aaa', kind: 'Lights', instanceId: 'living room', }), - ).toBe('wss://kody.example.com/connectors/u/user%20aaa/lights/living%20room') + ).toBe('wss://kody.example.com/@user-aaa/connectors/lights/living%20room') }) diff --git a/packages/worker/src/remote-connector/session.ts b/packages/worker/src/remote-connector/session.ts index 9ebeaf7821..2ef4aeb4e8 100644 --- a/packages/worker/src/remote-connector/session.ts +++ b/packages/worker/src/remote-connector/session.ts @@ -363,7 +363,7 @@ class RemoteConnectorSessionBase extends DurableObject { stringifyRemoteConnectorMessage({ type: 'server.error', message: - 'Connector ingress is missing the owning user id. Reconfigure the connector to use the /connectors/u/{userId}/{kind}/{instanceId} URL.', + 'Connector ingress is missing the owning user id. Reconfigure the connector to use the /@{username}/connectors/{kind}/{instanceId} URL.', }), ) ws.close(4002, 'missing-user') diff --git a/packages/worker/src/security/public-route-hardening.workers.test.ts b/packages/worker/src/security/public-route-hardening.workers.test.ts index 3303286108..22ec851347 100644 --- a/packages/worker/src/security/public-route-hardening.workers.test.ts +++ b/packages/worker/src/security/public-route-hardening.workers.test.ts @@ -17,17 +17,29 @@ async function workerFetch(request: Request): Promise { } test('user-scoped connector entrypoints reject unauthenticated HTTP access while allowing WebSocket upgrades', async () => { + await env.APP_DB.prepare( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL + )`, + ).run() + await env.APP_DB.prepare( + `INSERT OR IGNORE INTO users (username, email, password_hash) + VALUES ('connector-user', 'connector-user@example.com', 'hash')`, + ).run() const unauthorizedRequests = [ - createRequest('/connectors/u/user-1/lights/default/snapshot'), - createRequest('/connectors/u/user-1/lights/default/rpc/tools-list', { + createRequest('/@connector-user/connectors/lights/default/snapshot'), + createRequest('/@connector-user/connectors/lights/default/rpc/tools-list', { method: 'POST', }), - createRequest('/connectors/u/user-1/lights/default/rpc/tools-call', { + createRequest('/@connector-user/connectors/lights/default/rpc/tools-call', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'test', arguments: {} }), }), - createRequest('/connectors/u/user-1/lights/default/rpc/jsonrpc', { + createRequest('/@connector-user/connectors/lights/default/rpc/jsonrpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -42,7 +54,7 @@ test('user-scoped connector entrypoints reject unauthenticated HTTP access while } const websocketRequest = createRequest( - '/connectors/u/user-1/lights/default', + '/@connector-user/connectors/lights/default', { headers: { Upgrade: 'websocket' }, }, @@ -55,8 +67,8 @@ test('user-scoped connector entrypoints reject unauthenticated HTTP access while test('unmatched connector ingress paths do not fall through to the SPA shell', async () => { const requests = [ - createRequest('/connectors/home/default'), - createRequest('/connectors/home/default', { + createRequest('/@connector-user/connectors'), + createRequest('/@connector-user/connectors/home', { headers: { Upgrade: 'websocket' }, }), ] diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index f87320d233..baffd2e2a1 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -99,7 +99,13 @@ "assets": { "directory": "./public", "binding": "ASSETS", - "run_worker_first": ["/connectors/*", "/mcp-apps/*", "/styles.css"], + "run_worker_first": [ + "/@*/api/package-invocations/*", + "/@*/connectors/*", + "/@*/packages/*", + "/mcp-apps/*", + "/styles.css", + ], }, "durable_objects": { "bindings": [ @@ -183,7 +189,13 @@ "assets": { "directory": "./public", "binding": "ASSETS", - "run_worker_first": ["/connectors/*", "/mcp-apps/*", "/styles.css"], + "run_worker_first": [ + "/@*/api/package-invocations/*", + "/@*/connectors/*", + "/@*/packages/*", + "/mcp-apps/*", + "/styles.css", + ], }, "durable_objects": { "bindings": [ @@ -267,7 +279,13 @@ "assets": { "directory": "./public", "binding": "ASSETS", - "run_worker_first": ["/connectors/*", "/mcp-apps/*", "/styles.css"], + "run_worker_first": [ + "/@*/api/package-invocations/*", + "/@*/connectors/*", + "/@*/packages/*", + "/mcp-apps/*", + "/styles.css", + ], }, "durable_objects": { "bindings": [ diff --git a/tools/ci/resource-utils.node.test.ts b/tools/ci/resource-utils.node.test.ts index a9ec333fa0..9c324deaaf 100644 --- a/tools/ci/resource-utils.node.test.ts +++ b/tools/ci/resource-utils.node.test.ts @@ -82,7 +82,9 @@ test('writeGeneratedWranglerConfig copies production asset routing to the deploy } }>(generatedConfigText) - expect(generatedConfig.assets?.run_worker_first).toContain('/connectors/*') + expect(generatedConfig.assets?.run_worker_first).toContain( + '/@*/connectors/*', + ) expect(generatedConfig.assets).toEqual( generatedConfig.env?.production?.assets, ) @@ -115,7 +117,9 @@ test('writeGeneratedWranglerConfig copies preview asset routing to the deployed } }>(generatedConfigText) - expect(generatedConfig.assets?.run_worker_first).toContain('/connectors/*') + expect(generatedConfig.assets?.run_worker_first).toContain( + '/@*/connectors/*', + ) expect(generatedConfig.assets).toEqual(generatedConfig.env?.preview?.assets) } finally { await rm(tempDir, { force: true, recursive: true })