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
1 change: 1 addition & 0 deletions docs/contributing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ style, tests, MCP capabilities, and runtime architecture.
- [Setup](./setup.md), [code style](./code-style.md),
[testing](./testing-principles.md)
- [Packages and manifests](./packages-and-manifests.md)
- [External package invocation API](./package-invocation-api.md)
- [Adding capabilities](./adding-capabilities.md)
- [Architecture](./architecture/index.md)
- [MCP server patterns](./mcp-server-patterns.md) (reference for server design)
Expand Down
213 changes: 213 additions & 0 deletions docs/contributing/package-invocation-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# External package invocation API

Use this API when a trusted external service should invoke a saved package
export inside Kody without going through the interactive MCP transport.

The first intended caller is a stable Discord Gateway proxy:

1. the external proxy owns the long-lived Discord websocket
2. the proxy normalizes gateway events
3. the proxy calls Kody's package invocation API
4. Kody executes the saved package export with package context, user context,
package storage, and normal secret/capability rules

Kody remains the package runtime and storage brain. The external service owns
the socket lifecycle.

## Endpoint

`POST /api/package-invocations/:packageIdOrKodyId/:exportName`

Examples:

- `/api/package-invocations/pkg_123/dispatch-message-created`
- `/api/package-invocations/discord-gateway/dispatch-message-created`

The path accepts either:

- the saved package id
- the package `kody.id`

The export name is normalized to package export form, so
`dispatch-message-created` resolves as `./dispatch-message-created`.

## Authentication

Authentication uses a private bearer token stored in Kody's database-backed
package invocation token table.

Kody stores only the token hash for request-time lookup. The raw bearer token is
generated or collected by the token-management surface, shown to the operator
once, and then sent by the trusted external service as:

```http
Authorization: Bearer <raw-token>
```

Each token row includes:

- token id and human-readable name
- owning `user_id`, `email`, and `display_name`
- package scope (`package_ids_json` and/or `package_kody_ids_json`)
- allowed package exports (`export_names_json`)
- allowed request sources (`sources_json`)
- `last_used_at`
- `revoked_at`

The token is not a global backdoor:

- package lookup is still user-owned
- package access is scoped by the token row
- export access requires an explicit allowlist
- `source` metadata is checked against the allowlist when provided
- tokens can be revoked without deploys
- execution still uses normal package runtime machinery

## Request body

```json
{
"params": {
"messageId": "123",
"content": "hello"
},
"idempotencyKey": "discord:message-create:123",
"source": "discord-gateway",
"topic": "discord.message.created"
}
```

Fields:

- `params` — JSON object passed to the package export as runtime params
- `idempotencyKey` — required stable key for replay protection
- `source` — optional source label for auditing and token scoping; when present,
it must match the token's `sources_json` allowlist
- `topic` — optional event topic label for downstream logic and logs

Comment on lines +80 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

source is documented as optional but is effectively required by the runtime.

service.ts tokenAllowsSource() returns false when source is null/empty, which causes invokePackageExport to return 403 source_not_allowed before any package or export is even resolved. So a request omitting source always fails — it isn't "optional" in practice. Either tighten this doc to say source is required (and that it must match the token's sources_json allowlist), or update tokenAllowsSource to treat a missing source as "no source claim, skip allowlist check" if you actually want it optional.

The same friction shows up in the curl example at lines 196–205, where source is included — readers could reasonably remove it based on the "optional" description and get a confusing 403.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/contributing/package-invocation-api.md` around lines 80 - 86, The docs
claim `source` is optional but the runtime enforces it: `service.ts`'s
tokenAllowsSource() returns false for null/empty `source`, causing
invokePackageExport to fail with 403; fix by either (A) updating this docs page
and the curl example to state `source` is required and must match the token's
sources_json allowlist (clarify format and behavior), or (B) change
`tokenAllowsSource()` to treat a missing/empty `source` as "no source claim —
skip allowlist check" so omission is allowed; pick one approach and update the
docs and the implementation consistently (reference tokenAllowsSource and
invokePackageExport when making the change).

## Idempotency

Kody persists package invocation idempotency in D1.

The identity key is:

- user
- token id
- package id
- export name
- idempotency key

Behavior:

- same request + same idempotency key => stored response replayed
- same idempotency key + different payload => `409 idempotency_mismatch`
- duplicate while first invocation is still active =>
`409 invocation_in_progress`

This makes duplicate Discord gateway deliveries safe when the proxy retries.

## Response shape

Success:

```json
{
"ok": true,
"package": {
"id": "pkg_123",
"kodyId": "discord-gateway"
},
"exportName": "./dispatch-message-created",
"source": "discord-gateway",
"topic": "discord.message.created",
"idempotency": {
"key": "discord:message-create:123",
"replayed": false
},
"result": {
"reply": "handled"
},
"logs": []
}
```

Replay responses return the same stored body with `idempotency.replayed: true`.

Failures return:

```json
{
"ok": false,
"error": {
"code": "package_not_found",
"message": "Saved package \"discord-gateway\" was not found for this user."
}
}
```

Execution failures return sanitized structured errors and logs. The route does
not expose Worker secrets directly.

## Runtime behavior

The API reuses the existing published package bundle path:

- resolve the saved package for the configured user
- resolve the requested package export
- load the published `module` artifact if present
- rebuild and persist the module artifact on cache miss
- execute through `runBundledModuleWithRegistry`

Execution includes:

- package context
- repo context when a published source exists
- writable package storage bound to `package:<packageId>`
- user context from the scoped token config

## Rate limiting and auditing

The endpoint is shaped for standard Cloudflare edge rate limiting:

- path is stable and narrow
- caller metadata includes `source` and `topic`
- each request is audit-logged with hashed email/IP metadata

Prefer Cloudflare WAF/rate limiting rules in front of this path rather than
adding bespoke in-Worker rate limiting first.

## Discord gateway proxy pattern

Recommended flow:

1. keep the Discord Gateway websocket in a stable external process
2. receive `MESSAGE_CREATE`
3. derive a stable idempotency key from the gateway event
4. call the Kody endpoint
5. use Kody's structured result to decide what to post back to Discord

Example request:

```bash
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" \
-d '{
"params": {
"messageId": "123",
"channelId": "456",
"content": "hello"
},
"idempotencyKey": "discord:message-create:123",
"source": "discord-gateway",
"topic": "discord.message.created"
}'
```

## Related

- [Packages and manifests](./packages-and-manifests.md)
- [Environment variables](./environment-variables.md)
- [Setup manifest](./setup-manifest.md)
1 change: 1 addition & 0 deletions packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ CLOUDFLARE_EMAIL_FROM=reset@kody.dev
# Authorization: Bearer <this value> repopulates Vectorize after deploys. CI can set
# GitHub secret CAPABILITY_REINDEX_SECRET and sync it via deploy workflow.
# CAPABILITY_REINDEX_SECRET=

51 changes: 51 additions & 0 deletions packages/worker/migrations/0029-package-invocations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS package_invocation_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
package_ids_json TEXT NOT NULL DEFAULT '[]',
package_kody_ids_json TEXT NOT NULL DEFAULT '[]',
export_names_json TEXT NOT NULL DEFAULT '[]',
sources_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_package_invocation_tokens_user_id
ON package_invocation_tokens(user_id);

CREATE INDEX IF NOT EXISTS idx_package_invocation_tokens_token_hash
ON package_invocation_tokens(token_hash);

CREATE TABLE IF NOT EXISTS package_invocations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_id TEXT NOT NULL,
package_id TEXT NOT NULL,
package_kody_id TEXT NOT NULL,
export_name TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
source TEXT,
topic TEXT,
status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed', 'failed')),
response_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_package_invocations_identity
ON package_invocations(
user_id,
token_id,
package_id,
export_name,
idempotency_key
);

CREATE INDEX IF NOT EXISTS idx_package_invocations_user_created_at
ON package_invocations(user_id, created_at);
7 changes: 7 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import {
handleGeneratedUiApiRequest,
isGeneratedUiApiRequest,
} from './mcp/generated-ui-api.ts'
import {
handlePackageInvocationApiRequest,
isPackageInvocationApiRequest,
} from './package-invocations/http.ts'
import { withCors } from './utils.ts'
import { handleCapabilityReindexRequest } from './capability-maintenance.ts'
import { handleJobReindexRequest } from './job-maintenance.ts'
Expand Down Expand Up @@ -263,6 +267,9 @@ function addOAuthDiscoveryCorsHeaders(
const workerHandler = {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url)
if (isPackageInvocationApiRequest(url.pathname)) {
return handlePackageInvocationApiRequest(request, env, ctx)
}
Comment thread
cursor[bot] marked this conversation as resolved.

// OAuthProvider serves this URL first and defaults `resource` to the origin only.
// MCP clients must use `<origin>/mcp` as the resource (RFC 8707) to match our
Expand Down
Loading
Loading