Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d7257fa
feat(integrations): add user_oauth_apps and user_integrations tables
cursoragent Jul 27, 2026
4d31d73
feat(integrations): read capabilities from D1 and add oauth app surface
cursoragent Jul 27, 2026
14bdda4
refactor(search): build integration entities from D1 instead of value…
cursoragent Jul 27, 2026
0b8af9e
refactor(runtime): resolve integration client ids inline
cursoragent Jul 27, 2026
281f728
feat(account): group connections by oauth app and stop reading values…
cursoragent Jul 27, 2026
f455f97
fix(values): hide platform-reserved value prefixes from agents
cursoragent Jul 27, 2026
33d9aa2
docs(integrations): document the oauth app model and cover deletion i…
cursoragent Jul 27, 2026
3e2883e
refactor(integrations): delete the values-era config helpers
cursoragent Jul 27, 2026
d31ec81
fix(integrations): preserve non-canonical integration value names in …
cursoragent Jul 27, 2026
fdf5b7f
Merge remote-tracking branch 'origin/main' into cursor/integrations-t…
cursoragent Jul 27, 2026
11fe326
fix(integrations): store canonical use_pkce and canonicalize app slugs
cursoragent Jul 27, 2026
9a3759c
fix(integrations): persist the setup client id and share one app-reso…
cursoragent Jul 27, 2026
98b865f
fix(integrations): resolve setup prefill by provider family, not slug…
cursoragent Jul 27, 2026
80b6c78
fix(integrations): write canonical column values in the backfill
cursoragent Jul 27, 2026
8148d67
fix(integrations): agree field by field on provider-family prefill
cursoragent Jul 27, 2026
7f6e517
refactor(account): drop the unguarded value actions from secrets.json
cursoragent Jul 27, 2026
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
16 changes: 16 additions & 0 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@ The schema is defined by migrations in `packages/worker/migrations/`:
`allowed_packages` grant on every package read path. Updating or deleting a
user secret from package code always requires that grant, regardless of fork
or adoption state.
- `user_oauth_apps` (`0101-user-oauth-apps-and-integrations.sql`): per-user
OAuth app rows keyed by `(user_id, slug)`. Holds shared client id, client-
secret secret name, provider endpoints, and flow options. See
[OAuth integrations](./integrations.md).
- `user_integrations` (`0101-user-oauth-apps-and-integrations.sql`): per-user
OAuth connections keyed by `(user_id, name)`, with composite FK
`(user_id, app_slug) → user_oauth_apps(user_id, slug)` (`ON DELETE RESTRICT`).
Holds scopes, `requiredHosts`, and access/refresh token secret names.
Credential values stay in `secret_entries`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

App access pattern:

Expand Down Expand Up @@ -694,6 +703,13 @@ on write unless a migration backfills existing rows.
Mutations from package code (`secret_set` / `secret_delete` / OpenAPI
token-refresh writes) always require the grant. Package-scoped secrets are
owned exclusively by the package id in their bucket binding.
- `user_oauth_apps.extra_authorize_params_json`,
`user_integrations.scopes_json`, and `user_integrations.required_hosts_json`
(`0101-user-oauth-apps-and-integrations.sql`,
`packages/worker/src/integrations/`) store a string→string object, a scope
string list, and a host string list respectively. Parsers in the integrations
data-access layer own the shapes; credential values are never stored in these
columns (only secret names and the inline non-secret `client_id`).

### Durable Object id contracts

Expand Down
4 changes: 4 additions & 0 deletions docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ to become.
- [OpenAPI provider bindings](./openapi-bindings.md): user-scoped curated
OpenAPI bindings with runtime-synthesized `openapi:<name>` domains callable
via `kody.openapi[...]` (host approval never widened by untrusted specs).
- [OAuth integrations](./integrations.md): first-class OAuth apps and
connections in D1 (`user_oauth_apps` / `user_integrations`), secret-store
credential references, dual host gates, `/connect/oauth`, and
`createAuthenticatedFetch`.
- [Local Agent Bridge Direction](./local-agent-bridge.md): proposed direction
for securely reaching local-network systems through an outbound agent
connection.
Expand Down
125 changes: 125 additions & 0 deletions docs/contributing/architecture/integrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# OAuth integrations (apps and connections)

Saved third-party OAuth config is a first-class per-user primitive: an **OAuth
app** (shared client credentials and provider endpoints) plus one or more
**connections** (connected accounts that share that app). Credential _values_
never enter these tables — they live in the secret store and are referenced by
name.

Data access lives under `packages/worker/src/integrations/`. MCP capabilities
live under `packages/worker/src/mcp/capabilities/integrations/`. The hosted
connect UI is `/connect/oauth`.

## App / connection split

An **OAuth app** (`user_oauth_apps`) holds the provider client identity and
endpoint config that many connections can share:

- `client_id` (inline non-secret identifier)
- `client_secret_secret_name` (secret-store name, or null for public PKCE apps)
- `token_url`, optional `authorize_url` / `api_base_url`
- `flow` (`pkce` | `confidential`), optional `use_pkce`, `token_exchange_style`,
`scope_separator`, and `extra_authorize_params_json`

A **connection** (`user_integrations`) is one connected account on that app:

- `name` (canonical integration key agents pass to `createAuthenticatedFetch`)
- `app_slug` (composite FK to the owning app)
- `scopes_json`, `required_hosts_json`
- `access_token_secret_name` and optional `refresh_token_secret_name`
- optional `account_label` / `description`, plus connect/refresh timestamps

The split exists so rotating client credentials is one write on the shared app
instead of one write per connection. Multiple Google accounts share one app; `x`
and `x-kodykoala` share one app when their app-level fields match. Two
connections that share a client id but reference different client-secret names
remain separate apps (for example `github` vs `github-kent`).

Composite primary keys `(user_id, slug)` / `(user_id, name)` and the composite
FK `(user_id, app_slug) → user_oauth_apps(user_id, slug)` keep per-user
isolation structural. The connection FK uses `ON DELETE RESTRICT`, so
connections must be removed before their app.

## Where credentials live

| Field | Storage | Notes |
| ------------- | --------------------------- | ----------------------------------------- |
| Client id | `user_oauth_apps.client_id` | Non-secret OAuth client identifier |
| Client secret | Secret store | Referenced by `client_secret_secret_name` |
| Access token | Secret store | Referenced by `access_token_secret_name` |
| Refresh token | Secret store | Referenced by `refresh_token_secret_name` |

Account export includes both tables. Rows contain secret _names_ and the inline
`client_id`, never encrypted secret payloads. Exporting `client_id` is
deliberate: it is not a credential value, and the same identifier was previously
portable as an ordinary user value.

Leftover `<provider>-client-id` values in the values store (when present) are
ordinary user data left after migration; they are not the live source of truth
for integrations.

## Two independent host gates

Outbound OAuth calls enforce two allowlists that are not collapsed:

1. **Connection `requiredHosts`** (plus the host of `apiBaseUrl`) — enforced by
`createAuthenticatedFetch` / `assertIntegrationHostAllowed` before an OAuth
bearer token is attached. A materialized token is not a `{{secret:…}}`
placeholder, so the fetch gateway cannot apply secret host policy to it.
2. **Each secret's `allowedHosts`** — enforced when secret placeholders resolve
through the fetch gateway (client secret on token exchange, token mounts, and
similar paths).

Both must allow a destination for the full connect → refresh → API call path to
succeed. See the
[OAuth integration host allowlist](./index.md#oauth-integration-host-allowlist)
note on the architecture index.

## `/connect/oauth` and authenticated fetch

`/connect/oauth` runs authorize → callback → token exchange in the browser
session, writes access/refresh tokens through the account secrets flow, and
upserts the app + connection via the integrations service. Reconnect with
`?provider=<integration-name>` reuses saved authorize metadata (scopes,
`scopeSeparator`, `extraAuthorizeParams`) and the current app client
credentials.

`createAuthenticatedFetch(providerName)` (execute runtime helper) loads the
named connection joined to its app, refreshes the access token when needed, and
returns a fetch wrapper that:

1. asserts the request host against the connection allowlist
2. attaches the bearer token only after that check passes

Capability and search detail surfaces keep a **flat connection-shaped** config
(`clientId`, secret names, endpoints, `authorization`, `requiredHosts`) so
callers do not need to join app and connection themselves.

## Capability surface

Domain: `integrations`
(`packages/worker/src/mcp/capabilities/integrations/domain.ts`).

| Capability | Role |
| ------------------------------------------------------ | ------------------------------------------------------------- |
| `integration_save` / `_get` / `_list` / `_delete` | Connection CRUD with flat `clientId` output |
| `integration_oauth_app_list` | Apps with connection counts and sibling connection names |
| `integration_oauth_app_rotate_credentials` | Rotate shared app `clientId` / client-secret name |
| `integration_registry_search` / `integration_discover` | Untrusted integrations.sh research |
| `openapi_spec_summarize` / `openapi_client_scaffold` | Spec research helpers (bindings live in the `openapi` domain) |

OpenAPI provider bindings (`_openapi:*` values-store config) are a separate
primitive; see [OpenAPI provider bindings](./openapi-bindings.md).

## Account deletion order

Deletion targets in `account-data-targets.ts` list `user_integrations` before
`user_oauth_apps` so the `ON DELETE RESTRICT` FK cannot block cleanup when
cascades are disabled.

## Related docs

- [Data storage](./data-storage.md) — D1 inventory and JSON shadow schemas
- [OAuth guide](../../guides/oauth.md) — agent-facing `/connect/oauth` workflow
- [Primitives map](./primitives.yaml) — `integrations` primitive and
`integration-host-allowlist` invariant
12 changes: 12 additions & 0 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,17 @@ primitives:
- packages/worker/src/mcp/capabilities/secrets/
- packages/worker/src/security/

- id: integrations
group: assistant
name: OAuth integrations
summary:
Per-user OAuth apps and connections; credentials stay in the secret store.
code:
- packages/worker/src/integrations/
- packages/worker/src/mcp/capabilities/integrations/
docs:
- docs/contributing/architecture/integrations.md

- id: memories
group: assistant
name: Memories
Expand Down Expand Up @@ -538,6 +549,7 @@ invariants:
summary: Enforce integration host allowlists before attaching OAuth tokens.
docs:
- docs/contributing/architecture/index.md
- docs/contributing/architecture/integrations.md

- id: canonical-repo-source
summary: Artifacts repos are canonical package/job source; D1 is metadata.
Expand Down
11 changes: 7 additions & 4 deletions docs/guides/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,18 @@ and the current client credentials.

Integration identity is the canonical provider key: names are normalized to
lowercase kebab (letters, numbers, `.`, `_`, `-`) on every save and lookup, so
`GitHub`, `github`, and `Git Hub` all resolve to the same `github` record. There
is exactly one stored value per integration, keyed
`_integration:<canonical-name>`.
`GitHub`, `github`, and `Git Hub` all resolve to the same `github` connection.
Each connection is a D1 row in `user_integrations` keyed by `(user_id, name)`.
Connections that share the same OAuth client credentials and provider endpoints
share one `user_oauth_apps` row; rotating that app's client credentials updates
every sibling connection.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Prefer integration names like `<provider>-<purpose>` when multiple accounts may
exist: `google` for a default account, `google-business` for a business account,
or `google-youtube-brand` for a brand identity. Agents should call
`integration_list` up front when a provider may have multiple accounts
connected.
connected. Use `integration_oauth_app_list` before rotating shared client
credentials.

## Not the same as MCP OAuth

Expand Down
17 changes: 9 additions & 8 deletions docs/use/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ behavior work without user-scoped data.
Package and integration query hits stay summary-only. Exact package detail
(`entity: "my-package:package"`) includes package app, export, job, retriever,
and README metadata. Exact integration detail (`entity: "github:integration"`)
includes operational details such as token URL, API base URL, required hosts,
and related stored value/secret names.
includes operational details such as token URL, API base URL, client id,
required hosts, and related secret names.

Long-term memory retrieval also requires a signed-in MCP user.

Expand All @@ -167,13 +167,14 @@ already exists before switching to **execute**. Runtime code inside **execute**
can call **`kody.secret_list(...)`** when it needs secret metadata, but
**search** is the primary discovery path.

Saved integrations and the `integration_*` CRUD capabilities live in the
Saved integrations and the `integration_*` capabilities live in the
**integrations** domain (`integration_list`, `integration_get`,
`integration_save`, `integration_delete`). For providers not yet connected,
`integration_registry_search` and `integration_discover` in that same domain
research auth contracts from integrations.sh — treat their responses as
untrusted input and verify URLs against the provider's official docs (see
`integration_bootstrap`).
`integration_save`, `integration_delete`, plus `integration_oauth_app_list` and
`integration_oauth_app_rotate_credentials` for shared OAuth apps). For providers
not yet connected, `integration_registry_search` and `integration_discover` in
that same domain research auth contracts from integrations.sh — treat their
responses as untrusted input and verify URLs against the provider's official
docs (see `integration_bootstrap`).

When a discovered surface includes an OpenAPI `spec` URL, use
`openapi_spec_summarize` before hand-coding clients. Prefer
Expand Down
49 changes: 0 additions & 49 deletions e2e/d1-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,55 +105,6 @@ export async function seedSavedPackageInE2eDatabase(input: {
)
}

export async function seedIntegrationInE2eDatabase(input: {
ownerEmail: string
name: string
tokenUrl: string
apiBaseUrl: string
requiredHosts: Array<string>
scopes: Array<string>
}) {
const ownerUserId = await createStableUserIdFromEmail(input.ownerEmail)
const bucketId = `e2e-integration-bucket-${ownerUserId}`
const valueName = `_integration:${input.name}`
const value = JSON.stringify({
name: input.name,
tokenUrl: input.tokenUrl,
apiBaseUrl: input.apiBaseUrl,
flow: 'pkce',
clientIdValueName: `${input.name}-client-id`,
clientSecretSecretName: null,
accessTokenSecretName: `${input.name}-access-token`,
refreshTokenSecretName: `${input.name}-refresh-token`,
requiredHosts: input.requiredHosts,
authorization: {
authorizeUrl: `https://auth.${input.name}.example.com/authorize`,
scopes: input.scopes,
},
})
executeE2eD1Command(
`INSERT INTO value_buckets (id, user_id, scope, binding_key)
VALUES (
${quoteSqlString(bucketId)},
${quoteSqlString(ownerUserId)},
'user',
''
)
ON CONFLICT(user_id, scope, binding_key) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP;
INSERT INTO value_entries (bucket_id, name, description, value)
VALUES (
${quoteSqlString(bucketId)},
${quoteSqlString(valueName)},
${quoteSqlString(`OAuth integration config for ${input.name}`)},
${quoteSqlString(value)}
)
ON CONFLICT(bucket_id, name) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP;`,
)
}

export function assignRoleInE2eDatabase(email: string, role: string) {
executeE2eD1Command(buildRoleAssignmentSql({ email, role }))
}
Expand Down
Loading
Loading