Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/architecture/remote-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ calls to JSON-RPC on the socket.

## URLs and session keys

`wss://<worker-origin>/connectors/u/<userId>/<kind>/<instanceId>`
`wss://<worker-origin>/@<username>/connectors/<kind>/<instanceId>`

Session key = JSON tuple `[userId, kind, instanceId]` where `kind` is lowercase
after trim.
Expand Down Expand Up @@ -112,7 +112,7 @@ Source: `packages/shared/src/chat.ts`,
## Connector checklist

1. **Outbound WebSocket** to your connector URL:
`wss://<worker-origin>/connectors/u/<userId>/<kind>/<instanceId>`.
`wss://<worker-origin>/@<username>/connectors/<kind>/<instanceId>`.
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
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
12 changes: 4 additions & 8 deletions docs/contributing/package-invocation-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions e2e/remote-connectors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const remoteConnectorInstanceIdFieldSchema = createSchema<unknown, string>(
export const mcpUserContextSchema = object({
userId: string(),
email: string(),
username: optional(string()),
displayName: string(),
})

Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/public-urls.ts
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 4 additions & 4 deletions packages/shared/src/remote-connectors.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mcpCallerContextSchema>

Expand All @@ -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
}) {
Expand Down
12 changes: 6 additions & 6 deletions packages/worker/client/routes/account-remote-connectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type RemoteConnectorListItem = {
type AccountRemoteConnectorsPayload = {
ok: true
email: string
userId: string
username: string
connectorUrlOrigin: string
connectors: Array<RemoteConnectorListItem>
selectedConnectorId?: string
Expand Down Expand Up @@ -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<RemoteConnectorListItem> = []
let editorState = createEmptyEditorState()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -797,7 +797,7 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
</p>
)}
<p mix={css(descriptionCss)}>
Includes your MCP user ID <code>{userId}</code> so connector
Includes your username <code>{username}</code> so connector
sessions stay isolated to your account.
</p>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/app/authenticated-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export async function readAuthenticatedAppUser(request: Request, env: Env) {
mcpUser: {
userId: emailBasedUserId,
email: userRecord.email,
username: userRecord.username,
displayName,
},
} satisfies AuthenticatedAppUser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
})),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -145,16 +146,15 @@ 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: [
{
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,
Expand Down
7 changes: 3 additions & 4 deletions packages/worker/src/app/handlers/account-remote-connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type AccountRemoteConnectorListItem = {
type AccountRemoteConnectorsPayload = {
ok: true
email: string
userId: string
username: string
connectorUrlOrigin: string
connectors: Array<AccountRemoteConnectorListItem>
}
Expand Down Expand Up @@ -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,
}),
Expand Down
Loading
Loading