From 47227e23142faf26fae5f1d855ec4086a1e7a106 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:05:50 +0000 Subject: [PATCH 1/9] Export 33b01113 --- DEVELOPMENT.md | 360 +++++++++++++++++++++++++++++++ README.md | 413 ++++++++---------------------------- apps/cli/CHANGELOG.md | 4 + apps/cli/package.json | 2 +- apps/local/server/README.md | 2 +- bun.lock | 2 +- 6 files changed, 453 insertions(+), 330 deletions(-) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000000..711c2c453a --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,360 @@ +# Developing Executor + +The canonical development checkout is `~/agent-workspace/executor-next`. +Run `bun run workspace:check` before using the shared preview; use +`bun run workspace:check --task` on an isolated task branch. See +[the workspace lifecycle](AGENTS.md#workspace-lifecycle) for commits and merge handoff. + +Executor SDK, app framework and the first local server package. Account/app +persistence, encrypted credentials, deploying and tool execution now work. +The local MCP endpoint runs codemode over app tools and Executor management tools. +The local dashboard shows apps, accounts, live tools and retained source. +Provider OAuth supports supplied clients, DCR, CIMD, and token refresh. +Outbound MCP works over HTTP/SSE on local, self-host and cloud: custom remote +MCP imports and catalog MCP entries deploy, discover live upstream tools, and +call them, verified end to end including a real public server. Apps cannot +start local processes, so stdio MCP servers are not supported. The remote SDK facade +(`createRemoteExecutor`) is not implemented. + +Effect is pinned to an upstream snapshot with MCP `2026-07-28` support. +See [the dependency notes](notes/effect-mcp.md) for the revision and checks. + +Start with [project orientation](notes/project-orientation.md) and +[account connection and app configuration](notes/account-connections.md). +Repository launch commands load secrets directly from 1Password through `op run`. + +For the local/hosted split, start with the [code-sharing map](notes/code-sharing.md). +The first extracted capability is [catalog/import preparation](packages/catalog/README.md). +Local owns installation; cloud and Docker share a separate hosted product. +The hosted product serves a TanStack Router dashboard and +a shared Effect HTTP API on Cloudflare and Docker. Both have organization access +and persistent SDK inventory; cloud alone adds Autumn emulator billing. Alchemy +ships the frontend and Worker in one deployment; Docker bundles the frontend and Node server. + +To run your own instance, follow the public [self-host guide](apps/hosted/self-host/README.md). +It uses one Docker container with persistent storage and browser sign-in for MCP clients. + +Other references: [domain terms](CONTEXT.md), +[provider authoring](notes/provider-authoring.md), +[OAuth implementation and package choice](notes/oauth.md), +[shared context](notes/shared-app-context.md), +[app schemas](notes/app-schemas.md), +[Axiom with two accounts](notes/axiom-mcp-accounts.md), and +[runtime notes](notes/app-runtime.md). + +## Cloud onboarding E2E + +`bun run e2e:cloud --test-name 'Cloud onboarding'` starts the real local Cloud +Worker and disposable Postgres with hosted emulators. It needs Docker, Playwright +Chromium and ffmpeg, but no account secrets, saved Alchemy profile or 1Password. +See [recorded E2E tests](e2e/README.md) for the evidence viewer and attached stages. + +## Local development + +`bun run dev` needs no environment variables or 1Password. It serves the local +product with UI hot reload at `https://local.executor.localhost:5394`, or +`https://local..executor.localhost:5394` in another checkout, and prints +a one-use connect link. Any number of checkouts can run at once. Data and a +development key pair live in the checkout's ignored `.local/dev/`; delete it to +start fresh. `bun run server`, `bun run executor` and the desktop scripts use the +same defaults. Set `EXECUTOR_*` variables to override them; behind the proxy, the +port and browser origin always come from Portless. To use the 1Password values, +run `bun run with:local bun run dev`. + +## Development configuration + +Authenticate the 1Password CLI, then use the normal repository launch commands. +The `with:local`, `with:cloud`, `with:cloud:dev`, and `with:self-host` scripts in `package.json` +load 1Password references from ignored files through `op run`: + +- `.env.development.op` for optional local development overrides. +- `.env.production.op` for cloud. +- `.env.cloud-development.op` for the cloud Worker running locally. +- `.env.self-host.op` for Docker and standalone hosted Node. + +Use these scripts for other commands too: `bun run with:local node script.ts`. + +All `.env*` files are excluded from Git and Docker builds. Keep only references +in these files, never resolved credentials. Vault and item references also stay +out of tracked scripts. Edit values in 1Password, then restart the affected +process. Keep encryption/signing keys stable. + +Cloud uses Alchemy's PlanetScale database and role resources. Worker events reach +PlanetScale's PgBouncer through Hyperdrive; Durable Objects connect to PgBouncer directly. +`bun run hosted:cloud:dev` starts the cloud Worker, local Postgres, migrations, +and frontend through Alchemy with service emulators and no credentials. +`bun run hosted:cloud:dev:1password` uses `.env.cloud-development.op` instead; +configure `CLOUD_DEV_DATABASE_PASSWORD` in 1Password and use +`BETTER_AUTH_URL=https://127.0.0.1:5395`. See +[local cloud development](apps/hosted/README.md#local-cloud-development). +Self-host uses embedded PGlite with a persistent data volume. Add the required +references to the matching ignored file. Never put literal credentials there. +See [hosted configuration](apps/hosted/README.md#cloudflare) for PlanetScale settings. +Servers only read their process environment; deployed hosts use platform +secret bindings without requiring 1Password at runtime. + +## Layout + +Every package uses `src/contracts/`, `src/implementation/`, and `src/index.ts`. +Keep contracts flat, one cohesive area per file. + +- `packages/sdk/src/contracts/` + - `provider.ts`: normalized definitions, named auth methods and derived references + - `account.ts`: reusable accounts, API-key fields, OAuth start/completion and lookups + - `apps.ts`: apps, requirements, source, version and account errors + - `version.ts`: immutable source versions and retained builds + - `tools.ts`: existing tool seam, using accounts the product resolves from links + - `shared.ts`: branded IDs, owners, JSON and secret boundary types + - `http.ts`: one HTTP contract, composed from accounts/apps/tools + - `executor.ts`: the Promise SDK projected from that contract + - `storage.ts`: parsers for persisted accounts, versions and configured apps + - `runtime.ts`: pluggable build, inspect and call operations +- `packages/sdk/src/implementation/`: native account/app/tool operations, database + transactions, runtime adapters, provider identity and HTTP handlers +- `apps/local/server/`: local PGlite, credential encryption, configuration, API and MCP composition +- `apps/local/web/`: React dashboard with shadcn components and Effect Atom HTTP calls, catalog imports and account setup +- `apps/hosted/server/`: shared hosted HTTP contracts and handlers +- `apps/hosted/web/`: shared React pages, components, and Effect Atom API reads +- `apps/hosted/cloud/web/`: cloud dashboard routes, navigation, and browser entry +- `apps/hosted/self-host/web/`: self-host dashboard routes, navigation, and browser entry +- `apps/hosted/cloud/`: Cloudflare entry point and Alchemy v2 deployment +- `apps/hosted/self-host/`: Effect Node HTTP entry point and Docker image +- `packages/fumadb-effect/`: Effect SQL schema, typed query and migration implementation +- `packages/mcp/`: shared execute/skills contracts, codemode and search over product-authorized operations +- `packages/app-templates/`: source generators using the `apps` protocol subpaths for MCP, OpenAPI and GraphQL +- `packages/apps/`: provider/app authoring, shared context, schema constructors, + native Effect contracts, an author facade, a portable host handler and fetch decoding +- `playground/demo-apps/`: Vercel, Axiom, Mail, and Issue Mail authoring examples +- `playground/sdk/`: Promise SDK examples using package exports; no I/O at import time + +## Try reading these examples + +- [Vercel](playground/sdk/vercel.ts): deploy, connect an API-key account, save its + selection, and call a tool; same program for local and remote Executor +- [Axiom](playground/sdk/axiom.ts): two configured copies of one app and two OAuth sign-ins +- [Account reuse](playground/sdk/accounts.ts): list matching accounts, use one account + in several apps, and select multiple Gmail accounts +- [Two users](playground/sdk/two-users.ts): separate configured apps and accounts; + the product applies its own owner checks +- [Versions](playground/sdk/versions.ts): retained code and the existing rollback seam + +The in-process SDK takes `{ storage, sources, blobs, runtime, credentials }`. The Vercel example +can use those adapters; OAuth and remote examples remain sketches. Source-file +reads use Effect's filesystem service and assume the repository root. See the +[local server](apps/local/server/README.md) for resource ownership and API setup. + +## Storage schema + +`executorDatabase` declares providers, accounts, versions, apps, OAuth records +and configured-app documents with fumadb-effect. The caller supplies an Effect SQL +driver Layer, creates `makeExecutorStorage`, runs its `migrate` Effect, and owns +the connection lifetime. The [PGlite example](playground/sdk/storage.ts) initializes the schema +and round-trips a configured app without starting an SDK engine. + +Accounts store metadata and opaque encrypted credential bytes. The credential +adapter owns encryption, envelope format and key custody. The local server +uses AES-GCM with an explicitly configured key. Parsed storage +records redact the bytes; public account records contain only metadata. +Versions retain a Git commit reference, file count, build reference and declared +account requirements. Source reads load files from Git; SQL stores no source bytes. Public app requirements come from the active version. No +live tool catalog is stored. + +The schema enforces unique app names per owner, account provider references, +and active versions from the app's code lineage. Owners may differ across +related records. Saved account maps preserve missing slots, scalar IDs and +explicit empty arrays. SDK operations check account existence, provider matching, +cardinality and duplicate selections inside those maps. Versions are inserted +as immutable rows; selection and activation changes are transactional. + +Live queries track storage reads and update after committed writes. The dashboard +uses typed subscriptions; apps can declare async queries and mutations. See +[reactive storage](notes/reactive-storage.md) for the author model and runtime boundaries. + +## App runtime + +Every product runs authored apps and workflows in workerd, never in the host +Node process. Local and self-host use `workerdApps` from `@executor-js/sdk/node`; +cloud uses Dynamic Workers. Binary storage is supplied separately as `blobs`. +Standalone runtime callers use `createAppRuntime({ runtime, blobs })` for Promise +`build`, `inspect`, `query`, `mutate`, and `call` operations. See +[app runtime](notes/app-runtime.md) and [blob storage](notes/blob-storage.md). + +`apps/host` exposes `createAppHandler` and `hostContext` for other hosts. Its +framework-owned Request/Response protocol supports requirements, inspection and +calls. Trusted resolved accounts arrive separately from the request, are parsed +and redacted, and are decoded through each provider's native method schema. +The handler evaluates the app factory for every inspect/call, validates native +tool input, and returns parsed JSON. Error envelopes contain fixed error tags, +never author exceptions or credentials. It does not add app-authored HTTP routes. + +Builds use the `apps` release the app declares and remain tied to their location +and compatibility. Dependencies that install the Executor SDK are rejected. Native dependencies or packages needing install +scripts are not supported. JSON Schema metadata preserves +defaults, but Effect v4 can represent optional undefined branches as nullable; +native decoding remains authoritative. Outbound MCP works through this same +handler (apps calling out to remote MCP servers), and Cloudflare hosting is +implemented for cloud; see [hosted apps](notes/hosted-apps.md). + +## Model + +Providers are declared in app code. Matching normalized definitions yield the +same `ProviderId`; there is no manual provider registry, slug or provider owner. +The host exposes the reference and serialized definition through +`app.requirements.accounts.`. IDs use SHA-256 of the canonical JSON +definition, with object keys sorted and array order preserved. + +An account has its own required owner, provider, method and label. Credentials +stay out of returned metadata. Public SDK calls take plain credential fields; +host contracts redact them at entry. OAuth client configuration, state, PKCE, +refresh tokens and grant storage belong to the trusted host. + +An app owns source, versions, requirements and shared data. It holds no account selections. `apps.deploy` +creates, builds and activates a new app, keyed by `(owner,name)`. +`apps.copy({ from, owner, name })` copies running source into an independent app +with fresh Git history and deploys it. Unfinished apps copy their working source +and remain undeployed. Copies retain their origin but no accounts or app data. + +Operations that run app code take an explicit `accounts` map, nested by scope: +`{ app, user }`, each from requirement name to account IDs. App requirements are +filled by the app links, set for everyone by people who can edit the app, and +user requirements by the caller's user links; app-scope work, such as an app schedule, has no `user`. Every requirement +is named; a plain one names exactly one account and `.many()` any number, +including none. Products resolve the map, never the caller: a plain user +requirement uses its one linked account and `.many()` every linked account; see +[app accounts](notes/app-accounts.md). +Requirements apply to the whole app, including tools that use only part of its +context. + +`AppCodeId` groups the versions belonging to one independent app; it has no +separate CRUD API. A version retains immutable source, its deploying owner, +and a compiled-build reference. Code activation changes the app's pointer and +re-registers managed webhooks. New calls validate their accounts against the active +requirements. It does not copy accounts, rewrite links, or migrate app data. + +Tool calls identify the app and the accounts they use. The host snapshots the +version and account IDs for that invocation, checks product authorization, resolves current credentials, and +evaluates the dynamic app. An explicit version pins code only. The local MCP adapter evaluates catalogs for each execute and names tools by +configured app ID. Durable indexing and cross-call revision handling remain deferred. + +Each tool can declare its own `approval` callback or use `always()` / `never()` +from `apps/tools/approval`. The framework checks its decision after input validation +and before that tool runs. +See [tool policies](notes/app-policies.md) and [SDK call/resume](notes/sdk-tool-approvals.md). +MCP supports model-mediated resume and native client prompts; browser approval +pages remain separate work. See [MCP approvals](notes/mcp-resume.md). + +App authors import schema helpers such as `object`, `string`, and `array` +from `apps`. Effect validation stays internal. Every app's `package.json` +declares the exact `apps` version it uses, alongside any other dependencies. + +## Boundaries and deferred work + +The root `@executor-js/sdk` exports Promise-based `createExecutor` and +`createRemoteExecutor`. `@executor-js/sdk/core` exports the same constructors +and operations as Effects, with typed failures and native Stream subscriptions. +The local server uses `/core`; callers retain their Effect context, cancellation, +transactions, and live-query tracking. Both entry points use the same operations. +See [the Effect example](playground/sdk/effect.ts) and +[the Promise example](playground/sdk/vercel.ts). + +Framework and SDK operations use Effect v4 internally. App authors use ordinary async +functions; pure declarations and the schema parsing facade stay synchronous. +App factories, tools, webhooks and response readers have Effect-native internal +contracts. Public callbacks and schema helpers are adapted at the author boundary. +Owners are opaque product references and filters, not core permission rules. +Products enforce access to apps and reusable accounts, including cross-owner use. + +Webhooks support durable subscriptions, callback delivery and explicit lifecycle +reconciliation; see [webhooks](notes/webhooks.md). The remote SDK facade is not +implemented. General app +HTTP endpoint authoring, durable discovery indexes, app-to-app calls, rollout +policies, and app data migrations remain deferred. Scheduled work will use the +same saved account context; no scheduler API is introduced. Private local app +UIs and basic persistent app state with live queries are implemented. +See [deferred work](notes/deferred.md) for the current list, including workspaces. + +## MCP + +Run the [local server](apps/local/server/README.md), then connect a Streamable +HTTP MCP client to `http://127.0.0.1:4312/mcp` using the configured bearer key. +Append `?elicitation_mode=native` to use native client approval prompts. The default +`model` mode returns pending approvals for the agent to answer through `resume`. +It exposes `skills` for app documents and `execute({ code })` for programs. The +server's MCP instructions introduce Executor; they are the Executor app's +`executor` skill. Call `skills({})` to list documents and +`skills({ app: "executor", name: "executor" })` to read that entry point, which +links to the `code-mode` and `app-authoring` skills. Discover callable paths +inside execute: + +```js +return await tools.search({ query: "Executor" }); +``` + +The bundled Executor app can deploy source, connect API-key accounts, select +accounts and create more configured copies. It runs through the same app runtime +as those copies. Search again in a new execute after changing configuration. + +```js +// Substitute the exact paths returned by search. +return await Promise.all([ + tools["app_first"].listProjects({}), + tools["app_second"].listProjects({}), +]); +``` + +Each copy uses its own saved account selection. All targets use +`@opencode-ai/codemode` for `execute(code)`. The interpreter has no direct +filesystem, network, Node globals or imports. Its tool calls enter the product's +authorized app runtime. Authored apps run in workerd on Local, self-host and Cloud; +CodeMode's interpreter limits and cancellation remain cooperative. +See [MCP implementation and checks](notes/effect-mcp.md). + +## Local dashboard + +With the local keys configured as described in the [server setup](apps/local/server/README.md): + +```sh +bun run web:build +bun run server +``` + +Run `bun run executor` to start or join the background server and open an +authenticated dashboard; the server keeps running until +`bun run executor service stop`. Use `bun run executor serve` to run it in the +foreground without a browser, or `bun run executor pair` to get a fresh one-use +connection link for the running server. See the +[background service](apps/local/server/README.md#background-service). Browser +sessions use HttpOnly cookies; no API key is pasted or stored in the browser. +Apps and Accounts show persisted state. Open an app to inspect its live tools, selected +accounts, and retained source. Reads use Effect Atom with the server's typed +HTTP contract. Configuration remains available through MCP and the SDK. + +## Check + +```sh +bun install +bun run apps:build +bun run typecheck +``` + +## Desktop development + +Run `bun run desktop:dev` for the Electron app with hot reload, or +`bun run desktop:start` for the built dashboard. Both use the 1Password +configuration described in the desktop setup and a separate `.local/desktop` +data directory. On macOS, the launcher also creates `.local/desktop-runtime/Executor Dev.app`. See the +[desktop setup](apps/local/desktop/README.md) for lifecycle and verification. + +## Public export + +Development happens in this private repository. The public +`UsefulSoftwareCo/executor` repository receives snapshots on its `v2` branch +through `scripts/export-public.sh`. Paths listed in +`scripts/export-public.exclude`, such as `notes/`, stay private. The export +workflow runs the script on every push to `main`. See `RELEASING.md`. + +Pull requests against `v2` are ported into the private repository with the +contributor's `Co-Authored-By` trailer and a line naming the public pull request +and its full head sha, `Public-PR: UsefulSoftwareCo/executor# `. The next +export takes that head as another parent of its commit, so the pull request shows +as merged and its commits stay in `v2`'s history. diff --git a/README.md b/README.md index e7f1bd39cb..808207d90e 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,119 @@ -# executor-next +# Executor -The canonical development checkout is `~/agent-workspace/executor-next`. -Run `bun run workspace:check` before using the shared preview; use -`bun run workspace:check --task` on an isolated task branch. See -[the workspace lifecycle](AGENTS.md#workspace-lifecycle) for commits and merge handoff. +**Everything your agent needs, drivable as code.** -Executor SDK, app framework and the first local server package. Account/app -persistence, encrypted credentials, deploying and tool execution now work. -The local MCP endpoint runs codemode over app tools and Executor management tools. -The local dashboard shows apps, accounts, live tools and retained source. -Provider OAuth supports supplied clients, DCR, CIMD, and token refresh. -Outbound MCP works over HTTP/SSE on local, self-host and cloud: custom remote -MCP imports and catalog MCP entries deploy, discover live upstream tools, and -call them, verified end to end including a real public server. Apps cannot -start local processes, so stdio MCP servers are not supported. The remote SDK facade -(`createRemoteExecutor`) is not implemented. +Think of Executor as Zapier or Retool, but fully drivable as code, with +everything exposed to your agent. Connect the services you already use once, +and any agent can use them through one MCP endpoint, without ever seeing a +credential. -Effect is pinned to an upstream snapshot with MCP `2026-07-28` support. -See [the dependency notes](notes/effect-mcp.md) for the revision and checks. +[Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) · [Launch post](https://x.com/RhysSullivan/status/2118440383353172022) -Start with [project orientation](notes/project-orientation.md) and -[account connection and app configuration](notes/account-connections.md). -Repository launch commands load secrets directly from 1Password through `op run`. +https://github.com/user-attachments/assets/126c8117-2ef7-4f96-8fb8-8871728b0102 -For the local/hosted split, start with the [code-sharing map](notes/code-sharing.md). -The first extracted capability is [catalog/import preparation](packages/catalog/README.md). -Local owns installation; cloud and Docker share a separate hosted product. -The hosted product serves a TanStack Router dashboard and -a shared Effect HTTP API on Cloudflare and Docker. Both have organization access -and persistent SDK inventory; cloud alone adds Autumn emulator billing. Alchemy -ships the frontend and Worker in one deployment; Docker bundles the frontend and Node server. +## What it does -To run your own instance, follow the public [self-host guide](apps/hosted/self-host/README.md). -It uses one Docker container with persistent storage and browser sign-in for MCP clients. +You have an agent, and you want it to call something: an API, an MCP server, a +CLI, a service with no spec at all. Usually that means pasting the same keys into +every client, wiring the same servers up again, and writing an exception for +every API that does not fit the format. -Other references: [domain terms](CONTEXT.md), -[provider authoring](notes/provider-authoring.md), -[OAuth implementation and package choice](notes/oauth.md), -[shared context](notes/shared-app-context.md), -[app schemas](notes/app-schemas.md), -[Axiom with two accounts](notes/axiom-mcp-accounts.md), and -[runtime notes](notes/app-runtime.md). +Executor gives all of that one shape: the **app**. An app is a small TypeScript +program that turns your accounts into tools. One app can wrap an OpenAPI spec, +proxy a remote MCP server, or call an API by hand. It can also store data, +serve its own UI, run in the background, and ask before it does anything risky. -## Cloud onboarding E2E +- **Connect once, use everywhere.** Save an account (an API key or an OAuth + sign-in) once. Every app that needs the same service reuses it. The agent + never sees the credential; Executor attaches it inside the app. +- **Your agent writes code, not tool calls.** Agents get two tools, `skills` and + `execute`. They search for what they need and run a short program across as + many apps as the task takes. +- **Approvals where they matter.** Each tool decides when to ask first: always, + never, or only when the input looks risky. You approve through your agent, in + your MCP client's own prompt, or on a browser page. +- **Build what isn't there yet.** Install from the catalog, add a remote MCP + server by URL, or have your agent write and deploy a new app for you. Every + deploy is a new version you can roll back. +- **For teams too.** Hosted and self-host add organizations, groups, shared + accounts and browser sign-in. -`bun run e2e:cloud --test-name 'Cloud onboarding'` starts the real local Cloud -Worker and disposable Postgres with hosted emulators. It needs Docker, Playwright -Chromium and ffmpeg, but no account secrets, saved Alchemy profile or 1Password. -See [recorded E2E tests](e2e/README.md) for the evidence viewer and attached stages. +## Get started -## Local development +**Hosted.** Sign in at [app.executor.sh](https://app.executor.sh). There is +nothing to install. -`bun run dev` needs no environment variables or 1Password. It serves the local -product with UI hot reload at `https://local.executor.localhost:5394`, or -`https://local..executor.localhost:5394` in another checkout, and prints -a one-use connect link. Any number of checkouts can run at once. Data and a -development key pair live in the checkout's ignored `.local/dev/`; delete it to -start fresh. `bun run server`, `bun run executor` and the desktop scripts use the -same defaults. Set `EXECUTOR_*` variables to override them; behind the proxy, the -port and browser origin always come from Portless. To use the 1Password values, -run `bun run with:local bun run dev`. +**On your machine.** Your accounts and data stay local: -## Development configuration - -Authenticate the 1Password CLI, then use the normal repository launch commands. -The `with:local`, `with:cloud`, `with:cloud:dev`, and `with:self-host` scripts in `package.json` -load 1Password references from ignored files through `op run`: - -- `.env.development.op` for optional local development overrides. -- `.env.production.op` for cloud. -- `.env.cloud-development.op` for the cloud Worker running locally. -- `.env.self-host.op` for Docker and standalone hosted Node. - -Use these scripts for other commands too: `bun run with:local node script.ts`. - -All `.env*` files are excluded from Git and Docker builds. Keep only references -in these files, never resolved credentials. Vault and item references also stay -out of tracked scripts. Edit values in 1Password, then restart the affected -process. Keep encryption/signing keys stable. - -Cloud uses Alchemy's PlanetScale database and role resources. Worker events reach -PlanetScale's PgBouncer through Hyperdrive; Durable Objects connect to PgBouncer directly. -`bun run hosted:cloud:dev` starts the cloud Worker, local Postgres, migrations, -and frontend through Alchemy with service emulators and no credentials. -`bun run hosted:cloud:dev:1password` uses `.env.cloud-development.op` instead; -configure `CLOUD_DEV_DATABASE_PASSWORD` in 1Password and use -`BETTER_AUTH_URL=https://127.0.0.1:5395`. See -[local cloud development](apps/hosted/README.md#local-cloud-development). -Self-host uses embedded PGlite with a persistent data volume. Add the required -references to the matching ignored file. Never put literal credentials there. -See [hosted configuration](apps/hosted/README.md#cloudflare) for PlanetScale settings. -Servers only read their process environment; deployed hosts use platform -secret bindings without requiring 1Password at runtime. - -## Layout - -Every package uses `src/contracts/`, `src/implementation/`, and `src/index.ts`. -Keep contracts flat, one cohesive area per file. - -- `packages/sdk/src/contracts/` - - `provider.ts`: normalized definitions, named auth methods and derived references - - `account.ts`: reusable accounts, API-key fields, OAuth start/completion and lookups - - `apps.ts`: apps, requirements, source, version and account errors - - `version.ts`: immutable source versions and retained builds - - `tools.ts`: existing tool seam, using accounts the product resolves from links - - `shared.ts`: branded IDs, owners, JSON and secret boundary types - - `http.ts`: one HTTP contract, composed from accounts/apps/tools - - `executor.ts`: the Promise SDK projected from that contract - - `storage.ts`: parsers for persisted accounts, versions and configured apps - - `runtime.ts`: pluggable build, inspect and call operations -- `packages/sdk/src/implementation/`: native account/app/tool operations, database - transactions, runtime adapters, provider identity and HTTP handlers -- `apps/local/server/`: local PGlite, credential encryption, configuration, API and MCP composition -- `apps/local/web/`: React dashboard with shadcn components and Effect Atom HTTP calls, catalog imports and account setup -- `apps/hosted/server/`: shared hosted HTTP contracts and handlers -- `apps/hosted/web/`: shared React pages, components, and Effect Atom API reads -- `apps/hosted/cloud/web/`: cloud dashboard routes, navigation, and browser entry -- `apps/hosted/self-host/web/`: self-host dashboard routes, navigation, and browser entry -- `apps/hosted/cloud/`: Cloudflare entry point and Alchemy v2 deployment -- `apps/hosted/self-host/`: Effect Node HTTP entry point and Docker image -- `packages/fumadb-effect/`: Effect SQL schema, typed query and migration implementation -- `packages/mcp/`: shared execute/skills contracts, codemode and search over product-authorized operations -- `packages/app-templates/`: source generators using the `apps` protocol subpaths for MCP, OpenAPI and GraphQL -- `packages/apps/`: provider/app authoring, shared context, schema constructors, - native Effect contracts, an author facade, a portable host handler and fetch decoding -- `playground/demo-apps/`: Vercel, Axiom, Mail, and Issue Mail authoring examples -- `playground/sdk/`: Promise SDK examples using package exports; no I/O at import time - -## Try reading these examples - -- [Vercel](playground/sdk/vercel.ts): deploy, connect an API-key account, save its - selection, and call a tool; same program for local and remote Executor -- [Axiom](playground/sdk/axiom.ts): two configured copies of one app and two OAuth sign-ins -- [Account reuse](playground/sdk/accounts.ts): list matching accounts, use one account - in several apps, and select multiple Gmail accounts -- [Two users](playground/sdk/two-users.ts): separate configured apps and accounts; - the product applies its own owner checks -- [Versions](playground/sdk/versions.ts): retained code and the existing rollback seam - -The in-process SDK takes `{ storage, sources, blobs, runtime, credentials }`. The Vercel example -can use those adapters; OAuth and remote examples remain sketches. Source-file -reads use Effect's filesystem service and assume the repository root. See the -[local server](apps/local/server/README.md) for resource ownership and API setup. - -## Storage schema - -`executorDatabase` declares providers, accounts, versions, apps, OAuth records -and configured-app documents with fumadb-effect. The caller supplies an Effect SQL -driver Layer, creates `makeExecutorStorage`, runs its `migrate` Effect, and owns -the connection lifetime. The [PGlite example](playground/sdk/storage.ts) initializes the schema -and round-trips a configured app without starting an SDK engine. - -Accounts store metadata and opaque encrypted credential bytes. The credential -adapter owns encryption, envelope format and key custody. The local server -uses AES-GCM with an explicitly configured key. Parsed storage -records redact the bytes; public account records contain only metadata. -Versions retain a Git commit reference, file count, build reference and declared -account requirements. Source reads load files from Git; SQL stores no source bytes. Public app requirements come from the active version. No -live tool catalog is stored. - -The schema enforces unique app names per owner, account provider references, -and active versions from the app's code lineage. Owners may differ across -related records. Saved account maps preserve missing slots, scalar IDs and -explicit empty arrays. SDK operations check account existence, provider matching, -cardinality and duplicate selections inside those maps. Versions are inserted -as immutable rows; selection and activation changes are transactional. - -Live queries track storage reads and update after committed writes. The dashboard -uses typed subscriptions; apps can declare async queries and mutations. See -[reactive storage](notes/reactive-storage.md) for the author model and runtime boundaries. - -## App runtime - -Every product runs authored apps and workflows in workerd, never in the host -Node process. Local and self-host use `workerdApps` from `@executor-js/sdk/node`; -cloud uses Dynamic Workers. Binary storage is supplied separately as `blobs`. -Standalone runtime callers use `createAppRuntime({ runtime, blobs })` for Promise -`build`, `inspect`, `query`, `mutate`, and `call` operations. See -[app runtime](notes/app-runtime.md) and [blob storage](notes/blob-storage.md). - -`apps/host` exposes `createAppHandler` and `hostContext` for other hosts. Its -framework-owned Request/Response protocol supports requirements, inspection and -calls. Trusted resolved accounts arrive separately from the request, are parsed -and redacted, and are decoded through each provider's native method schema. -The handler evaluates the app factory for every inspect/call, validates native -tool input, and returns parsed JSON. Error envelopes contain fixed error tags, -never author exceptions or credentials. It does not add app-authored HTTP routes. - -Builds use the `apps` release the app declares and remain tied to their location -and compatibility. Dependencies that install the Executor SDK are rejected. Native dependencies or packages needing install -scripts are not supported. JSON Schema metadata preserves -defaults, but Effect v4 can represent optional undefined branches as nullable; -native decoding remains authoritative. Outbound MCP works through this same -handler (apps calling out to remote MCP servers), and Cloudflare hosting is -implemented for cloud; see [hosted apps](notes/hosted-apps.md). - -## Model - -Providers are declared in app code. Matching normalized definitions yield the -same `ProviderId`; there is no manual provider registry, slug or provider owner. -The host exposes the reference and serialized definition through -`app.requirements.accounts.`. IDs use SHA-256 of the canonical JSON -definition, with object keys sorted and array order preserved. - -An account has its own required owner, provider, method and label. Credentials -stay out of returned metadata. Public SDK calls take plain credential fields; -host contracts redact them at entry. OAuth client configuration, state, PKCE, -refresh tokens and grant storage belong to the trusted host. - -An app owns source, versions, requirements and shared data. It holds no account selections. `apps.deploy` -creates, builds and activates a new app, keyed by `(owner,name)`. -`apps.copy({ from, owner, name })` copies running source into an independent app -with fresh Git history and deploys it. Unfinished apps copy their working source -and remain undeployed. Copies retain their origin but no accounts or app data. - -Operations that run app code take an explicit `accounts` map, nested by scope: -`{ app, user }`, each from requirement name to account IDs. App requirements are -filled by the app links, set for everyone by people who can edit the app, and -user requirements by the caller's user links; app-scope work, such as an app schedule, has no `user`. Every requirement -is named; a plain one names exactly one account and `.many()` any number, -including none. Products resolve the map, never the caller: a plain user -requirement uses its one linked account and `.many()` every linked account; see -[app accounts](notes/app-accounts.md). -Requirements apply to the whole app, including tools that use only part of its -context. - -`AppCodeId` groups the versions belonging to one independent app; it has no -separate CRUD API. A version retains immutable source, its deploying owner, -and a compiled-build reference. Code activation changes the app's pointer and -re-registers managed webhooks. New calls validate their accounts against the active -requirements. It does not copy accounts, rewrite links, or migrate app data. - -Tool calls identify the app and the accounts they use. The host snapshots the -version and account IDs for that invocation, checks product authorization, resolves current credentials, and -evaluates the dynamic app. An explicit version pins code only. The local MCP adapter evaluates catalogs for each execute and names tools by -configured app ID. Durable indexing and cross-call revision handling remain deferred. - -Each tool can declare its own `approval` callback or use `always()` / `never()` -from `apps/tools/approval`. The framework checks its decision after input validation -and before that tool runs. -See [tool policies](notes/app-policies.md) and [SDK call/resume](notes/sdk-tool-approvals.md). -MCP supports model-mediated resume and native client prompts; browser approval -pages remain separate work. See [MCP approvals](notes/mcp-resume.md). - -App authors import schema helpers such as `object`, `string`, and `array` -from `apps`. Effect validation stays internal. Every app's `package.json` -declares the exact `apps` version it uses, alongside any other dependencies. - -## Boundaries and deferred work +```sh +curl -fsSL https://executor.sh/docs/install.sh | sh +executor +``` -The root `@executor-js/sdk` exports Promise-based `createExecutor` and -`createRemoteExecutor`. `@executor-js/sdk/core` exports the same constructors -and operations as Effects, with typed failures and native Stream subscriptions. -The local server uses `/core`; callers retain their Effect context, cancellation, -transactions, and live-query tracking. Both entry points use the same operations. -See [the Effect example](playground/sdk/effect.ts) and -[the Promise example](playground/sdk/vercel.ts). +`executor` starts Executor in the background and opens the dashboard. npm, +Homebrew and Windows installs are in the [command line guide](https://executor.sh/docs/run/cli). -Framework and SDK operations use Effect v4 internally. App authors use ordinary async -functions; pure declarations and the schema parsing facade stay synchronous. -App factories, tools, webhooks and response readers have Effect-native internal -contracts. Public callbacks and schema helpers are adapted at the author boundary. -Owners are opaque product references and filters, not core permission rules. -Products enforce access to apps and reusable accounts, including cross-owner use. +**On your own infrastructure.** One Docker container with an embedded database. +See [Self-host with Docker](https://executor.sh/docs/run/self-host). -Webhooks support durable subscriptions, callback delivery and explicit lifecycle -reconciliation; see [webhooks](notes/webhooks.md). The remote SDK facade is not -implemented. General app -HTTP endpoint authoring, durable discovery indexes, app-to-app calls, rollout -policies, and app data migrations remain deferred. Scheduled work will use the -same saved account context; no scheduler API is introduced. Private local app -UIs and basic persistent app state with live queries are implemented. -See [deferred work](notes/deferred.md) for the current list, including workspaces. +All three run the same apps and serve the same MCP endpoint. -## MCP +## Connect your agent -Run the [local server](apps/local/server/README.md), then connect a Streamable -HTTP MCP client to `http://127.0.0.1:4312/mcp` using the configured bearer key. -Append `?elicitation_mode=native` to use native client approval prompts. The default -`model` mode returns pending approvals for the agent to answer through `resume`. -It exposes `skills` for app documents and `execute({ code })` for programs. The -server's MCP instructions introduce Executor; they are the Executor app's -`executor` skill. Call `skills({})` to list documents and -`skills({ app: "executor", name: "executor" })` to read that entry point, which -links to the `code-mode` and `app-authoring` skills. Discover callable paths -inside execute: +Point any MCP client at your endpoint. With Claude Code and hosted Executor: -```js -return await tools.search({ query: "Executor" }); +```sh +claude mcp add --transport http --scope user executor 'https://mcp.executor.sh/mcp' ``` -The bundled Executor app can deploy source, connect API-key accounts, select -accounts and create more configured copies. It runs through the same app runtime -as those copies. Search again in a new execute after changing configuration. +Then run `/mcp` inside Claude Code to sign in. The local endpoint is +`http://127.0.0.1:4312/mcp`. ChatGPT, Codex, Cursor and others are covered in +[Add an MCP client](https://executor.sh/docs/mcp-clients). -```js -// Substitute the exact paths returned by search. -return await Promise.all([ - tools["app_first"].listProjects({}), - tools["app_second"].listProjects({}), -]); -``` +Once connected, ask your agent what it can do: "Show me the apps and tools in +my Executor workspace." -Each copy uses its own saved account selection. All targets use -`@opencode-ai/codemode` for `execute(code)`. The interpreter has no direct -filesystem, network, Node globals or imports. Its tool calls enter the product's -authorized app runtime. Authored apps run in workerd on Local, self-host and Cloud; -CodeMode's interpreter limits and cancellation remain cooperative. -See [MCP implementation and checks](notes/effect-mcp.md). +## Build an app -## Local dashboard +An app is ordinary async TypeScript. This one has a single tool: -With the local keys configured as described in the [server setup](apps/local/server/README.md): +```ts +import { defineApp, object, query, router, string } from "apps"; -```sh -bun run web:build -bun run server +export default defineApp( + {}, + { + tools: router({ + greet: query( + { description: "Greet someone by name", input: object({ name: string() }) }, + async (_ctx, { name }) => ({ message: `Hello, ${name}!` }), + ), + }), + }, +); ``` -Run `bun run executor` to start or join the background server and open an -authenticated dashboard; the server keeps running until -`bun run executor service stop`. Use `bun run executor serve` to run it in the -foreground without a browser, or `bun run executor pair` to get a fresh one-use -connection link for the running server. See the -[background service](apps/local/server/README.md#background-service). Browser -sessions use HttpOnly cookies; no API key is pasted or stored in the browser. -Apps and Accounts show persisted state. Open an app to inspect its live tools, selected -accounts, and retained source. Reads use Effect Atom with the server's typed -HTTP contract. Configuration remains available through MCP and the SDK. - -## Check +Declare the accounts it needs and Executor asks the person using it to connect +them. Read [Author an app](https://executor.sh/docs/author-an-app) for accounts, +approvals, stored data and deploying. Or skip the reading and ask your agent to +build one: it can read the same guide through the `skills` tool. -```sh -bun install -bun run apps:build -bun run typecheck -``` +## Learn more -## Desktop development +- [What Executor is](https://executor.sh/docs): accounts, apps and tools in two minutes +- [Providers and accounts](https://executor.sh/docs/concepts/providers-and-accounts): + what Executor stores and what an app sees +- [Tools and approvals](https://executor.sh/docs/concepts/tools-and-approvals): + how calls are checked before they run +- [MCP](https://executor.sh/docs/mcp): code mode, `skills`, `execute` and resuming approvals -Run `bun run desktop:dev` for the Electron app with hot reload, or -`bun run desktop:start` for the built dashboard. Both use the 1Password -configuration described in the desktop setup and a separate `.local/desktop` -data directory. On macOS, the launcher also creates `.local/desktop-runtime/Executor Dev.app`. See the -[desktop setup](apps/local/desktop/README.md) for lifecycle and verification. +## Contributing -## Public export +Issues and pull requests are welcome. [DEVELOPMENT.md](DEVELOPMENT.md) covers +running Executor from source, the repository layout and how the pieces fit +together. Executor 1 lives on the [`main`](https://github.com/UsefulSoftwareCo/executor/tree/main) +branch. -Development happens in this private repository. The public -`UsefulSoftwareCo/executor` repository receives snapshots on its `v2` branch -through `scripts/export-public.sh`. Paths listed in -`scripts/export-public.exclude`, such as `notes/`, stay private. The export -workflow runs the script on every push to `main`. See `RELEASING.md`. +## License -Pull requests against `v2` are ported into the private repository with the -contributor's `Co-Authored-By` trailer and a line naming the public pull request -and its full head sha, `Public-PR: UsefulSoftwareCo/executor# `. The next -export takes that head as another parent of its commit, so the pull request shows -as merged and its commits stay in `v2`'s history. +[MIT](LICENSE) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 811c84f4cd..6cc0744715 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # executor +## 2.0.0-beta.37 + +Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. + ## 2.0.0-beta.36 Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. diff --git a/apps/cli/package.json b/apps/cli/package.json index 5713e1e820..1e0fbffbc2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "2.0.0-beta.36", + "version": "2.0.0-beta.37", "private": true, "description": "Run Executor locally and connect your agents to your apps.", "homepage": "https://executor.sh", diff --git a/apps/local/server/README.md b/apps/local/server/README.md index 0b40a527bd..3042a2068a 100644 --- a/apps/local/server/README.md +++ b/apps/local/server/README.md @@ -51,7 +51,7 @@ Press Ctrl+C to stop `bun run server` or `bun run dev`. Package-level `bun run start` reads its existing process environment only. Relative data paths are resolved from the process working directory. `bun run with:local …` loads 1Password values instead; see -[configuration](../../../README.md#development-configuration). +[configuration](../../../DEVELOPMENT.md#development-configuration). ## Configuration diff --git a/bun.lock b/bun.lock index 0c706cd746..84da374ec9 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ }, "apps/cli": { "name": "executor", - "version": "2.0.0-beta.36", + "version": "2.0.0-beta.37", }, "apps/docs": { "name": "@executor-js/docs", From 326328f94e7af47367ae99a67e920a30ca727adf Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:56:05 +0000 Subject: [PATCH 2/9] Export d9c7951b --- .changeset/credential-check-advice.md | 7 + .../implementation/pages/connect-account.tsx | 7 +- .../src/implementation/pages/account-form.tsx | 7 +- e2e/tests/account-check-failures.spec.ts | 27 +++- .../implementation/dashboard/account-form.tsx | 148 +++++++++++------- 5 files changed, 135 insertions(+), 61 deletions(-) create mode 100644 .changeset/credential-check-advice.md diff --git a/.changeset/credential-check-advice.md b/.changeset/credential-check-advice.md new file mode 100644 index 0000000000..c2fc5e0535 --- /dev/null +++ b/.changeset/credential-check-advice.md @@ -0,0 +1,7 @@ +--- +"@executor-js/ui": patch +--- + +When an app's check rejects or can't verify credentials in the connect form, the +form now says the check may be wrong, that the credential can still be added, +and offers a fix prompt that asks the user's agent to correct the app's check. diff --git a/apps/hosted/web/src/implementation/pages/connect-account.tsx b/apps/hosted/web/src/implementation/pages/connect-account.tsx index 257f77247f..7f1d5640cf 100644 --- a/apps/hosted/web/src/implementation/pages/connect-account.tsx +++ b/apps/hosted/web/src/implementation/pages/connect-account.tsx @@ -243,8 +243,11 @@ export function HostedAccountForm({ {...(app === undefined || app === null ? {} : { - check: (input: AccountSubmission) => - checkCredentials({ organization, app, provider: provider.id, ...input }), + check: { + app, + run: (input: AccountSubmission) => + checkCredentials({ organization, app, provider: provider.id, ...input }), + }, })} onSaved={saved} {...(onPendingChange ? { onPendingChange } : {})} diff --git a/apps/local/web/src/implementation/pages/account-form.tsx b/apps/local/web/src/implementation/pages/account-form.tsx index 8e717cb1b5..67f9214898 100644 --- a/apps/local/web/src/implementation/pages/account-form.tsx +++ b/apps/local/web/src/implementation/pages/account-form.tsx @@ -85,8 +85,11 @@ export function AppAccountForm({ submit={(input: AccountSubmission) => submit(input)} {...(checkWith ? { - check: (input: AccountSubmission) => - checkCredentials({ app, provider: provider.id, ...input }), + check: { + app, + run: (input: AccountSubmission) => + checkCredentials({ app, provider: provider.id, ...input }), + }, } : {})} oauth={(props) => ( diff --git a/e2e/tests/account-check-failures.spec.ts b/e2e/tests/account-check-failures.spec.ts index 3faf40bdac..a67b72d07b 100644 --- a/e2e/tests/account-check-failures.spec.ts +++ b/e2e/tests/account-check-failures.spec.ts @@ -112,10 +112,35 @@ export default defineApp({ user: { accounts: { service } } }, async () => ({ too status: "check_failed", message: "403: token [redacted] lacks the accounts scope", }); + // The check is the app's own code, so the form says it may be wrong and saving stays open. expect(yield* verdict("The form shows the app's explanation")).toBe( - "Couldn't verify this API key: 403: token [redacted] lacks the accounts scope", + "Couldn't verify this API key: 403: token [redacted] lacks the accounts scope\n" + + "The app's check may be wrong. You can still add this API key and ask your agent to fix the check.\n" + + "Copy fix prompt", ); yield* browser.checkpoint("App explains why the check failed"); + // The fix prompt names the app and quotes what its check reported, without the credential. + const prompt = yield* browser.use( + "Copy the prompt that asks an agent to fix the check", + (page) => + page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]) + .then(() => + connect.getByRole("button", { name: "Copy fix prompt", exact: true }).click(), + ) + .then(() => page.evaluate(() => navigator.clipboard.readText())), + ); + expect(prompt).toBe( + [ + `The account check in my Executor app (app ID: ${app.id}) failed for my Check fixture API key, and the check itself may be wrong.`, + "", + "The check reported: Couldn't verify this API key: 403: token [redacted] lacks the accounts scope", + "", + "Start with the Executor app's executor skill. Read the app's source, find the health check for the Check fixture provider, and fix it so it accepts a working API key and still rejects a bad one. Deploy the updated app. Don't ask me to paste the API key into chat.", + ].join("\n"), + ); + expect(prompt).not.toContain(missingScope); yield* browser.use("Narrow screen", (page) => page.setViewportSize({ width: 390, height: 844 }), ); diff --git a/packages/ui/src/implementation/dashboard/account-form.tsx b/packages/ui/src/implementation/dashboard/account-form.tsx index 8db729dc79..bec3160af3 100644 --- a/packages/ui/src/implementation/dashboard/account-form.tsx +++ b/packages/ui/src/implementation/dashboard/account-form.tsx @@ -1,6 +1,12 @@ import { useState, type ComponentType, type ReactNode } from "react"; import { Exit, Match, Option, Redacted, type Cause } from "effect"; -import type { Account, CredentialCheck, Provider } from "@executor-js/sdk"; +import type { + Account, + AccountCheckStatus, + AppId, + CredentialCheck, + Provider, +} from "@executor-js/sdk"; import { HugeiconsIcon } from "@hugeicons/react"; import { AlertCircleIcon, @@ -19,6 +25,7 @@ import { } from "../../contracts/credentials.ts"; import { CredentialFields } from "../components/credential-fields.tsx"; import { Button } from "../components/button.tsx"; +import { CopyButton } from "./code.tsx"; import { Select, SelectContent, @@ -47,7 +54,10 @@ export function AccountForm({ * Check complete credentials before saving, with the app that will use them. The form shows * whether they work; saving stays the user's choice. Null means the app defines no check. */ - readonly check?: (input: AccountSubmission) => Promise>; + readonly check?: { + readonly app: AppId; + readonly run: (input: AccountSubmission) => Promise>; + }; readonly provider: Provider; readonly account?: Pick; readonly header?: ReactNode; @@ -111,7 +121,7 @@ export function AccountForm({ const input = { method, fields: Redacted.make(credentialValues(fields, values)) }; if (!validates || check === undefined || verdict.state !== "idle") return save(input); setVerdict({ state: "checking" }); - void check(input).then((exit) => { + void check.run(input).then((exit) => { // An app without a check has nothing to validate, so saving continues. if (Exit.isSuccess(exit) && exit.value === null) { setVerdict({ state: "idle" }); @@ -180,8 +190,9 @@ export function AccountForm({ onChange={changeValues} pending={pending} /> - {validates && auth?.type === "secrets" && ( + {check !== undefined && auth?.type === "secrets" && ( , + provider: string, + label: string, + message: string | undefined, +) => + Match.value(status).pipe( + Match.when("credentials_rejected", () => `${provider} rejected this ${label}`), + Match.when("forbidden", () => `This ${label} is missing a permission the app needs`), + Match.when("upstream_unavailable", () => `Couldn't reach ${provider} to check it`), + Match.when( + "check_failed", + () => `Couldn't verify this ${label}${message === undefined ? "" : `: ${message}`}`, + ), + Match.exhaustive, + ); + +/** Hands a check that may be wrong to the user's agent, which fixes and redeploys the app. */ +const checkFixPrompt = (app: AppId, provider: string, label: string, failure: string) => + [ + `The account check in my Executor app (app ID: ${app}) failed for my ${provider} ${label}, and the check itself may be wrong.`, + "", + `The check reported: ${failure}`, + "", + `Start with the Executor app's executor skill. Read the app's source, find the health check for the ${provider} provider, and fix it so it accepts a working ${label} and still rejects a bad one. Deploy the updated app. Don't ask me to paste the ${label} into chat.`, + ].join("\n"); + /** * Whether the entered credentials work, as the app's check found. The line keeps its height while * empty, so a short result never moves the form; the app's own explanation of a failure may wrap. */ function CredentialVerdict({ + app, verdict, label, provider, }: { + readonly app: AppId; readonly verdict: Verdict; readonly label: string; readonly provider: string; @@ -376,57 +417,24 @@ function CredentialVerdict({ ? ({ status: "check_failed", info: null } as const) : undefined; const name = shown?.info?.displayName ?? shown?.info?.username ?? shown?.info?.email; - const [tone, message] = + const failure = + shown === undefined || shown.status === "healthy" + ? undefined + : checkFailure(shown.status, provider, label, shown.message); + const tone = shown === undefined - ? (["muted", null] as const) - : Match.value(shown.status).pipe( - Match.when( - "healthy", - () => - [ - "good", - name === undefined ? ( - <>Valid {label} - ) : ( - <> - Signed in as {name} - - ), - ] as const, - ), - Match.when( - "credentials_rejected", - () => - [ - "bad", - <> - {provider} rejected this {label} - , - ] as const, - ), - Match.when( - "forbidden", - () => ["warn", <>This {label} is missing a permission the app needs] as const, - ), - Match.when( - "upstream_unavailable", - () => ["warn", <>Couldn't reach {provider} to check it] as const, - ), - Match.when( - "check_failed", - () => - [ - "warn", - <> - Couldn't verify this {label} - {shown.message !== undefined ? `: ${shown.message}` : null} - , - ] as const, - ), - Match.exhaustive, - ); + ? "muted" + : shown.status === "healthy" + ? "good" + : shown.status === "credentials_rejected" + ? "bad" + : "warn"; + // The app's author wrote its check, so a failure that blames the credentials may be the check's + // own bug. An unreachable service says nothing about either. + const doubtsCheck = + failure !== undefined && shown !== undefined && shown.status !== "upstream_unavailable"; return ( -

- {message !== null && ( + {shown !== undefined && ( <> - {message} +

+ + {failure ?? + (name === undefined ? ( + <>Valid {label} + ) : ( + <> + Signed in as {name} + + ))} + + {doubtsCheck && ( + <> + + The app's check may be wrong. You can still add this {label} and ask your agent to + fix the check. + +
+ +
+ + )} +
)} -

+ ); } From 7661912af49d33582596e77e3ee1357ce592f667 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:36:51 +0000 Subject: [PATCH 3/9] Export b0da0415 --- apps/hosted/cloud/src/app-ui.ts | 13 +- .../implementation/auth-audit-vocabulary.ts | 179 +++++ .../cloud/src/implementation/auth-audit.ts | 269 ++++++++ .../src/implementation/auth-observability.ts | 9 + .../cloud/src/implementation/auth-refusals.ts | 113 ++++ .../src/implementation/client-network.ts | 58 ++ .../implementation/organization-removal.ts | 8 +- .../cloud/src/implementation/v1-import/run.ts | 9 + .../src/implementation/v1-mcp-forward.ts | 46 +- .../src/implementation/v1-session-handoff.ts | 15 +- .../cloud/src/infrastructure/app-sessions.ts | 11 +- apps/hosted/cloud/src/infrastructure/auth.ts | 32 +- .../cloud/src/infrastructure/mcp-auth.ts | 6 +- apps/hosted/cloud/src/main.ts | 21 +- apps/hosted/server/src/contracts/api.ts | 4 +- apps/hosted/server/src/contracts/app-ui.ts | 10 +- apps/hosted/server/src/contracts/auth.ts | 84 ++- apps/hosted/server/src/contracts/mcp.ts | 14 +- .../server/src/contracts/security-audit.ts | 79 +++ .../server/src/implementation/access.ts | 10 +- .../server/src/implementation/accounts.ts | 10 +- apps/hosted/server/src/implementation/api.ts | 3 +- .../server/src/implementation/app-sessions.ts | 40 +- .../server/src/implementation/app-ui.ts | 26 +- .../src/implementation/auth-telemetry.ts | 125 ++++ apps/hosted/server/src/implementation/auth.ts | 18 +- .../src/implementation/authorization.ts | 13 +- .../src/implementation/bearer-access.ts | 169 +++-- .../src/implementation/mcp-connections.ts | 15 +- .../server/src/implementation/mcp-http.ts | 6 +- .../server/src/implementation/mcp-oauth.ts | 14 +- .../src/implementation/oauth-clients.ts | 21 +- .../server/src/implementation/organization.ts | 47 +- .../src/implementation/security-audit.ts | 89 +++ apps/hosted/server/src/index.ts | 19 + e2e/ci-selection.ts | 2 +- e2e/test-plan.ts | 11 + e2e/tests/cloud-impersonation.spec.ts | 247 ++++++- e2e/tests/mcp-organization-spans.spec.ts | 71 +- e2e/tests/oauth-refresh-resilience.spec.ts | 6 + e2e/tests/organization-request-spans.spec.ts | 107 ++- e2e/tests/security-observability.spec.ts | 633 ++++++++++++++++++ packages/apps/package.json | 2 +- packages/mcp-auth/src/implementation/oauth.ts | 5 +- packages/sdk/src/implementation/oauth.ts | 48 +- packages/telemetry/src/http.ts | 2 + packages/telemetry/src/request-router.ts | 20 + packages/telemetry/src/request-span.ts | 40 ++ packages/telemetry/src/span-attributes.ts | 3 + 49 files changed, 2567 insertions(+), 235 deletions(-) create mode 100644 apps/hosted/cloud/src/implementation/auth-audit-vocabulary.ts create mode 100644 apps/hosted/cloud/src/implementation/auth-audit.ts create mode 100644 apps/hosted/cloud/src/implementation/auth-refusals.ts create mode 100644 apps/hosted/cloud/src/implementation/client-network.ts create mode 100644 apps/hosted/server/src/contracts/security-audit.ts create mode 100644 apps/hosted/server/src/implementation/auth-telemetry.ts create mode 100644 apps/hosted/server/src/implementation/security-audit.ts create mode 100644 e2e/tests/security-observability.spec.ts create mode 100644 packages/telemetry/src/request-router.ts create mode 100644 packages/telemetry/src/request-span.ts diff --git a/apps/hosted/cloud/src/app-ui.ts b/apps/hosted/cloud/src/app-ui.ts index e34f10a01a..16584e75e8 100644 --- a/apps/hosted/cloud/src/app-ui.ts +++ b/apps/hosted/cloud/src/app-ui.ts @@ -1,4 +1,4 @@ -import { requestServices } from "@executor-js/hosted-server"; +import { recordUnclassifiedRefusals, requestServices } from "@executor-js/hosted-server"; import { previewLifetime } from "./infrastructure/test-stage-expiry.ts"; /** Private app-origin entry point. Dashboard assets and management APIs are never mounted here. */ import { hostedAppUi, appAddresses } from "@executor-js/hosted-server/app-ui"; @@ -10,7 +10,12 @@ import { Config, Effect, Layer, Option } from "effect"; import { HttpApiBuilder } from "effect/http-api"; import { HttpRouter, HttpServer, HttpServerError, HttpServerResponse } from "effect/http"; import { cloudAppUiBase, cloudAppUiPort, cloudAppUiRoute } from "./contracts/app-ui.ts"; -import { recordRequestRejections, requestTiming } from "@executor-js/telemetry/http"; +import { + toRequestHttpEffect, + recordRequestRejections, + requestTiming, +} from "@executor-js/telemetry/http"; +import { recordClientNetwork } from "./implementation/client-network.ts"; import { cloudSentry } from "./implementation/error-reporting.ts"; import { cloudAnalytics } from "./implementation/product-analytics.ts"; import { postHogBindings } from "./infrastructure/posthog.ts"; @@ -99,7 +104,7 @@ export default class AppPages extends Cloudflare.Worker()( ).pipe(Layer.provide(appUi.originAccess.layer)); const handle = yield* routes.pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, yield* Layer.makeMemoMap), ); const lifetime = yield* previewLifetime; @@ -124,6 +129,8 @@ export default class AppPages extends Cloudflare.Worker()( : Effect.succeed(HttpServerResponse.empty({ status: 500, headers: appPrivateHeaders })), ), requestTiming, + recordClientNetwork, + recordUnclassifiedRefusals, lifetime.http, ), }; diff --git a/apps/hosted/cloud/src/implementation/auth-audit-vocabulary.ts b/apps/hosted/cloud/src/implementation/auth-audit-vocabulary.ts new file mode 100644 index 0000000000..3c4b413841 --- /dev/null +++ b/apps/hosted/cloud/src/implementation/auth-audit-vocabulary.ts @@ -0,0 +1,179 @@ +/** + * The closed vocabularies the Better Auth security audit (`auth-audit.ts`) reads: which endpoints + * it audits and how, the failure codes a record may name, the decoders for the request body + * fields and stored answers it reads, and how an endpoint's answer decodes to its outcome. + * Anything outside them is never recorded as given. + */ +import { PASSKEY_ERROR_CODES } from "@better-auth/passkey"; +import { BASE_ERROR_CODES } from "better-auth"; +import { APIError } from "better-auth/api"; +import { Option, Schema } from "effect"; +import { auditFailureCodes } from "@executor-js/hosted-server"; +import { oauthErrorCodes } from "./auth-observability.ts"; + +/** + * How each sign-in endpoint signs in. A session it creates is a success. Other endpoints also set + * a new session, such as `/get-session` when it extends one, so only these count as sign-ins. + */ +export const signIns: Readonly> = { + "/sign-in/email-otp": "email_otp", + "/sign-in/social": "social", + "/callback/:id": "social", + "/oauth-proxy-callback": "social", + "/passkey/verify-authentication": "passkey", + "/sign-in/sso": "sso", + "/sso/callback": "sso", + "/sso/callback/:providerId": "sso", + "/sso/saml2/sp/acs/:providerId": "sso", + "/v1-handoff/session": "v1_handoff", +}; +/** Endpoints that check a one-time code outside sign-in, where a wrong code is the event. */ +export const otpChecks = new Set([ + "/email-otp/verify-email", + "/email-otp/check-verification-otp", + "/email-otp/request-email-change", + "/email-otp/change-email", + "/email-otp/reset-password", +]); +/** Endpoints that end sessions. */ +export const revocations = new Set([ + "/sign-out", + "/revoke-session", + "/revoke-sessions", + "/revoke-other-sessions", +]); +/** The other audited endpoints: API keys, members, invitations, OAuth clients, impersonation. */ +const actions = new Set([ + "/api-key/create", + "/api-key/delete", + "/organization/update-member-role", + "/organization/remove-member", + "/organization/invite-member", + "/organization/accept-invitation", + "/organization/cancel-invitation", + "/oauth2/register", + "/oauth2/consent", + "/admin/impersonate-user", + "/admin/stop-impersonating", +]); +export const audited = (path: string | undefined) => + path !== undefined && + (path in signIns || otpChecks.has(path) || revocations.has(path) || actions.has(path)); + +/** + * The failure codes these endpoints answer with: Better Auth's own and its passkey plugin's, the + * codes of the email-code, organization and API key plugins they raise, the OAuth errors a sign-in + * returns with, and an HTTP status where the answer carries no code. Anything else is `other`. + */ +export const failureOf = auditFailureCodes([ + ...Object.keys(BASE_ERROR_CODES), + ...Object.keys(PASSKEY_ERROR_CODES), + // Email codes. + "OTP_EXPIRED", + "INVALID_OTP", + "TOO_MANY_ATTEMPTS", + // Organization members and invitations. + "MEMBER_NOT_FOUND", + "NO_ACTIVE_ORGANIZATION", + "ORGANIZATION_NOT_FOUND", + "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED", + "ROLE_NOT_FOUND", + "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION", + "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION", + "INVITATION_NOT_FOUND", + "INVITATION_LIMIT_REACHED", + "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION", + "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION", + "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER", + "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER", + "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION", + "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION", + "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE", + "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION", + "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER", + // API keys. + "KEY_NOT_FOUND", + "UNAUTHORIZED_SESSION", + "INVALID_NAME_LENGTH", + "NAME_REQUIRED", + // Impersonation. + "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS", + "YOU_CANNOT_IMPERSONATE_ADMINS", + ...oauthErrorCodes, + ...Array.from({ length: 200 }, (_, offset) => String(400 + offset)), +]); + +const Id = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)); +/** + * The request body fields the endpoints read, each decoded on its own. Better Auth ignores a field + * an endpoint does not take, so one such field's value must not hide another's. + */ +export const decodeKeyId = Schema.decodeUnknownOption(Schema.Struct({ keyId: Id })); +export const decodeRole = Schema.decodeUnknownOption( + Schema.Struct({ role: Schema.Literals(["owner", "admin", "member"]) }), +); +export const decodeAccept = Schema.decodeUnknownOption(Schema.Struct({ accept: Schema.Boolean })); +export const decodeOAuthQuery = Schema.decodeUnknownOption( + Schema.Struct({ oauth_query: Schema.String }), +); +/** The stored records the endpoints answer with. */ +const Returned = Schema.Struct({ + id: Schema.optionalKey(Id), + client_id: Schema.optionalKey(Id), + organizationId: Schema.optionalKey(Id), + metadata: Schema.optionalKey( + Schema.NullOr(Schema.Struct({ organization: Schema.optionalKey(Id) })), + ), + member: Schema.optionalKey(Schema.Struct({ id: Id, organizationId: Id })), + invitation: Schema.optionalKey(Schema.Struct({ id: Id, organizationId: Id })), +}); +const ErrorBody = Schema.Struct({ code: Schema.optionalKey(Schema.String) }); +export const decodeReturned = Schema.decodeUnknownOption(Returned); +const decodeError = Schema.decodeUnknownOption(ErrorBody); +export const decodeId = Schema.decodeUnknownOption(Id); + +/** A signed-in session, as a hook or the endpoint's authentication loaded it. */ +export interface Session { + readonly user: { readonly id: string }; + readonly session: { readonly id: string; readonly impersonatedBy?: string | null | undefined }; +} +/** The structural part of Better Auth's hook context this module reads. */ +export interface HookContext { + readonly path?: string | undefined; + readonly body?: unknown; + readonly context: { + readonly returned?: unknown; + readonly newSession?: + | { + readonly user: { readonly id: string }; + readonly session: { readonly impersonatedBy?: string | null | undefined }; + } + | null + | undefined; + readonly session?: Session | null | undefined; + readonly responseHeaders?: Headers | undefined; + }; +} + +/** + * The endpoint's outcome: failure with Better Auth's error code, or the `error` a redirect carries + * back to the browser; success otherwise. A redirect is an `APIError` whatever it means. + */ +export const outcome = (ctx: HookContext) => { + const returned = ctx.context.returned; + if (returned instanceof APIError && returned.statusCode >= 400) + return failureOf( + Option.getOrUndefined(decodeError(returned.body))?.code ?? String(returned.statusCode), + ); + if (returned instanceof Response && returned.status >= 400) + return failureOf(String(returned.status)); + const location = + (returned instanceof APIError ? new Headers(returned.headers).get("location") : null) ?? + ctx.context.responseHeaders?.get("location") ?? + null; + const error = + location === null + ? null + : URL.parse(location, "https://auth.invalid")?.searchParams.get("error"); + return error === null || error === undefined ? undefined : failureOf(error); +}; diff --git a/apps/hosted/cloud/src/implementation/auth-audit.ts b/apps/hosted/cloud/src/implementation/auth-audit.ts new file mode 100644 index 0000000000..3048d44305 --- /dev/null +++ b/apps/hosted/cloud/src/implementation/auth-audit.ts @@ -0,0 +1,269 @@ +/** + * Security audit events for Better Auth's own endpoints: sign-in, one-time codes, sessions, API + * keys, organization members and invitations, and OAuth client registration and consent. + * + * A record names only what the request's authentication and the endpoint's stored result + * establish. The actor is the session already on the hook context: one a before hook loaded, or + * the product analytics hook's read of the signed-in session for the operations it counts. Better + * Auth's endpoint middleware does not leave its own read there. This module never reads a session + * itself, so auditing adds no query and no cookie to any answer; an endpoint whose session nothing + * else loaded is recorded without its actor. A target's ID and + * an organization come from what the endpoint stored and answered with, after it succeeded; a + * refused request names neither, since what it asked for can be any text. A failure is recorded + * by Better Auth's code only when the code is one these endpoints answer with (`failureOf`). + * Never an email address, a code, a token or a key: `/api-key/create` answers with the new key's + * value beside its ID, and only the ID is read. + * + * A session revocation is recorded where Better Auth deletes the session row, one record per + * deleted session. A revocation endpoint that answers success without deleting one, as sign-out + * does when the deletion fails, is recorded as `unconfirmed`. + * + * An admin's impersonation is its own action, not a sign-in: its actor is the admin and its target + * the impersonated user, both from the session rows Better Auth stored or deleted. A session an + * impersonation created carries the admin as `impersonatedBy`, which every record made in it names. + */ +import type { BetterAuthPlugin } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; +import { Option } from "effect"; +import type { SecurityAuditAction, SecurityAuditEvent } from "@executor-js/hosted-server"; +import { + audited, + decodeAccept, + decodeId, + decodeKeyId, + decodeOAuthQuery, + decodeReturned, + decodeRole, + otpChecks, + outcome, + revocations, + signIns, + type HookContext, + type Session, +} from "./auth-audit-vocabulary.ts"; + +/** + * The audited event for a completed request, or none for an endpoint this module does not audit. + * `confirmed` tells whether a revocation deleted a session; `impersonated` is the user whose + * impersonation session `/admin/stop-impersonating` deleted. + */ +const auditedEvent = ( + ctx: HookContext, + confirmed: boolean, + impersonated: string | undefined, +): SecurityAuditEvent | undefined => { + const path = ctx.path ?? ""; + const failure = outcome(ctx); + const user = ctx.context.session?.user.id; + // Only a successful endpoint's answer and input name stored records. + const returned = + failure === undefined ? Option.getOrUndefined(decodeReturned(ctx.context.returned)) : undefined; + const event = ( + action: SecurityAuditAction, + rest: Partial = {}, + ): SecurityAuditEvent => ({ + action, + outcome: failure === undefined ? "success" : "failure", + failure, + user, + ...rest, + }); + const method = signIns[path]; + // A sign-in endpoint that created a session signed someone in, and one that reports an error + // refused a sign-in. Starting a social sign-in, or a social return that links an account to the + // signed-in user, does neither. + if (method !== undefined) { + const created = ctx.context.newSession?.user.id; + if (created !== undefined) + return { action: "sign_in", outcome: "success", user: created, detail: { method } }; + return failure === undefined + ? undefined + : event("sign_in", { user: undefined, detail: { method } }); + } + if (otpChecks.has(path)) return failure === undefined ? undefined : event("otp.verify"); + // Each deleted session was recorded where it was deleted. + if (revocations.has(path)) { + const kind = path === "/sign-out" ? "session" : "sessions"; + if (failure !== undefined) return event("session.revoke", { target: { kind } }); + return confirmed + ? undefined + : event("session.revoke", { outcome: "unconfirmed", target: { kind } }); + } + const role = Option.match(decodeRole(ctx.body), { + onNone: () => ({}), + onSome: (body) => ({ detail: { role: body.role } }), + }); + switch (path) { + case "/api-key/create": + // `apiKeyManagement` creates a pinned key only for an organization its creator belongs to. + return event("api_key.create", { + target: { kind: "api_key", id: returned?.id }, + organization: returned?.metadata?.organization, + }); + case "/api-key/delete": + // The endpoint deletes only the caller's own key with exactly this ID, and answers with no + // record, so a deletion names the ID it found. + return event("api_key.delete", { + target: { + kind: "api_key", + id: + failure === undefined ? Option.getOrUndefined(decodeKeyId(ctx.body))?.keyId : undefined, + }, + }); + case "/organization/update-member-role": + return event("member.role_change", { + target: { kind: "member", id: returned?.id }, + organization: returned?.organizationId, + ...role, + }); + case "/organization/remove-member": + return event("member.remove", { + target: { kind: "member", id: returned?.member?.id }, + organization: returned?.member?.organizationId, + }); + case "/organization/invite-member": + return event("invitation.create", { + target: { kind: "invitation", id: returned?.id }, + organization: returned?.organizationId, + ...role, + }); + case "/organization/accept-invitation": + return event("invitation.accept", { + target: { kind: "invitation", id: returned?.invitation?.id }, + organization: returned?.invitation?.organizationId, + }); + case "/organization/cancel-invitation": + return event("invitation.cancel", { + target: { kind: "invitation", id: returned?.id }, + organization: returned?.organizationId, + }); + case "/oauth2/register": + return event("oauth_client.register", { + target: { kind: "oauth_client", id: returned?.client_id }, + }); + case "/oauth2/consent": { + // A consent the endpoint accepted names the client its signed query resolved to. + const query = + failure === undefined ? Option.getOrUndefined(decodeOAuthQuery(ctx.body)) : undefined; + const client = + query === undefined + ? undefined + : (new URLSearchParams(query.oauth_query).get("client_id") ?? undefined); + // The endpoint allows only `accept: true`. A body whose `accept` is not a boolean names no + // decision; it is never taken for an allow. + const accept = Option.getOrUndefined(decodeAccept(ctx.body))?.accept; + return event("oauth.consent", { + target: { kind: "oauth_client", id: Option.getOrUndefined(decodeId(client)) }, + detail: accept === undefined ? undefined : { decision: accept ? "allow" : "deny" }, + }); + } + case "/admin/impersonate-user": { + // The session the endpoint stored is the impersonated user's, and names as `impersonatedBy` + // the admin whose session the endpoint verified. A refusal names no target. + const started = failure === undefined ? ctx.context.newSession : undefined; + return event("impersonation.start", { + user: started?.session.impersonatedBy ?? user, + target: { kind: "user", id: started?.user.id }, + }); + } + case "/admin/stop-impersonating": + // The endpoint deleted the impersonation session and restored the admin's own, which it + // checked belongs to the session's `impersonatedBy`. + return event("impersonation.stop", { + user: failure === undefined ? (ctx.context.newSession?.user.id ?? user) : user, + target: { kind: "user", id: failure === undefined ? impersonated : undefined }, + }); + default: + return undefined; + } +}; + +/** A session row Better Auth deleted. */ +interface DeletedSession { + readonly id: string; + readonly userId: string; +} + +/** + * The plugin. Register it after the product analytics plugin, whose session read it reuses. + * `record` receives each event with the session that made the request, if one was loaded, and + * must never reject. + */ +export const authAudit = ( + record: ( + event: SecurityAuditEvent, + /** + * The signed-in session that made the request, by its row ID, and the admin who impersonates + * its user when an impersonation created it. + */ + session: + | { readonly id: string; readonly user: string; readonly impersonatedBy?: string | undefined } + | undefined, + ) => Promise, +): BetterAuthPlugin => { + /** Requests, by their hook context, in which a revocation deleted a session. */ + const confirmed = new WeakSet(); + /** Requests, by their hook context, that ended an impersonation: the impersonated user. */ + const impersonations = new WeakMap(); + const caller = (session: Session | null | undefined) => + session === null || session === undefined + ? undefined + : { + id: session.session.id, + user: session.user.id, + ...(typeof session.session.impersonatedBy === "string" + ? { impersonatedBy: session.session.impersonatedBy } + : {}), + }; + return { + id: "executor-security-audit", + init: () => ({ + options: { + databaseHooks: { + session: { + delete: { + after: async (deleted: DeletedSession, context) => { + const path = context?.path; + if (context === null || path === undefined) return; + if (path === "/admin/stop-impersonating") { + impersonations.set(context.context, deleted.userId); + return; + } + if (!revocations.has(path)) return; + confirmed.add(context.context); + // Sign-out deletes the session its cookie names, the request's own credential. + const session = + caller(context.context.session) ?? + (path === "/sign-out" ? { id: deleted.id, user: deleted.userId } : undefined); + await record( + { + action: "session.revoke", + outcome: "success", + target: { kind: "session", id: deleted.id }, + user: deleted.userId, + }, + session, + ); + }, + }, + }, + }, + }, + }), + hooks: { + after: [ + { + matcher: (ctx) => audited(ctx.path), + handler: createAuthMiddleware(async (ctx) => { + const event = auditedEvent( + ctx, + confirmed.has(ctx.context), + impersonations.get(ctx.context), + ); + if (event !== undefined) await record(event, caller(ctx.context.session)); + }), + }, + ], + }, + }; +}; diff --git a/apps/hosted/cloud/src/implementation/auth-observability.ts b/apps/hosted/cloud/src/implementation/auth-observability.ts index a982667d3d..92b374a33a 100644 --- a/apps/hosted/cloud/src/implementation/auth-observability.ts +++ b/apps/hosted/cloud/src/implementation/auth-observability.ts @@ -52,6 +52,15 @@ const RegistrationError = Schema.Literals([ "invalid_target", "access_denied", ]); +/** + * Every OAuth error code Executor's sign-in returns, token endpoint and registration answer with. + * Other records that name an OAuth error accept only these. + */ +export const oauthErrorCodes: ReadonlyArray = [ + ...KnownError.literals, + ...TokenError.literals, + ...RegistrationError.literals, +]; /** * Why Better Auth refused a registration, matched against the fixed opening of its own * `error_description`. The description itself names client input and is never recorded. diff --git a/apps/hosted/cloud/src/implementation/auth-refusals.ts b/apps/hosted/cloud/src/implementation/auth-refusals.ts new file mode 100644 index 0000000000..25556a31a4 --- /dev/null +++ b/apps/hosted/cloud/src/implementation/auth-refusals.ts @@ -0,0 +1,113 @@ +/** + * Why Better Auth refused a request to its own endpoints with 401 or 403, from the fixed error + * code it answers with: `code` for its own endpoints, `error` for the OAuth ones. The code itself + * is recorded as `auth.failure.code`; its message, which can name what the client sent, never is. + * + * A reason is recorded only for an endpoint and code whose every refusal has that one cause, read + * from Better Auth's source. A code an endpoint raises for several causes, such as + * `/organization/update-member-role`'s `YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER` (another + * organization's member, the creator rule or the caller's role), names none, and + * `recordUnclassifiedRefusals` records it as `unclassified`. + */ +import { + recordAuthFailure, + recordNoCredential, + type AuthFailureReason, +} from "@executor-js/hosted-server"; +import { annotateRequestSpan } from "@executor-js/telemetry/http"; +import { Effect, Option, Schema } from "effect"; + +const ErrorBody = Schema.fromJsonString( + Schema.Struct({ + code: Schema.optionalKey(Schema.String), + error: Schema.optionalKey(Schema.String), + }), +); +const decodeError = Schema.decodeUnknownOption(ErrorBody); + +/** Refusals of the origin check that runs before every endpoint. */ +const anyEndpoint: Readonly> = { + INVALID_ORIGIN: "origin_mismatch", + MISSING_OR_NULL_ORIGIN: "origin_mismatch", +}; +/** The one-time code checks, which refuse a code past its allowed attempts. */ +const otpEndpoints = [ + "/sign-in/email-otp", + "/email-otp/verify-email", + "/email-otp/check-verification-otp", + "/email-otp/request-email-change", + "/email-otp/change-email", + "/email-otp/reset-password", +]; +/** Invitation endpoints that refuse a signed-in user the invitation was not sent to. */ +const recipientEndpoints = [ + "/organization/accept-invitation", + "/organization/reject-invitation", + "/organization/get-invitation", +]; +/** + * Endpoint and code pairs with one cause. The role refusals follow a lookup of the caller's own + * membership in the organization they act on, so only the role can refuse them. + */ +const byEndpoint: Readonly>>> = { + ...Object.fromEntries( + otpEndpoints.map((path) => [path, { TOO_MANY_ATTEMPTS: "attempts_exceeded" as const }]), + ), + ...Object.fromEntries( + recipientEndpoints.map((path) => [ + path, + { YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION: "wrong_resource" as const }, + ]), + ), + "/organization/invite-member": { + YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION: "insufficient_role", + YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE: "insufficient_role", + }, + "/organization/cancel-invitation": { + YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION: "insufficient_role", + }, + "/organization/remove-member": { YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER: "insufficient_role" }, + "/organization/update": { YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION: "insufficient_role" }, + "/organization/list-members": { YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION: "not_member" }, + "/organization/get-full-organization": { + USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION: "not_member", + }, + "/organization/set-active": { USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION: "not_member" }, +}; + +/** Better Auth's base path on every host (`authOptions`); the tables name paths below it. */ +const basePath = "/api/auth"; + +/** + * Record why Better Auth answered `status` to the request for `pathname`, given its answer's body + * and the request's headers. + */ +export const recordAuthRefusal = ( + status: number, + pathname: string, + body: Uint8Array, + headers: Headers, +) => + Effect.gen(function* () { + if (status !== 401 && status !== 403) return; + const path = pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname; + const answer = Option.getOrUndefined(decodeError(new TextDecoder().decode(body))); + const code = answer?.code ?? answer?.error; + if (code !== undefined && /^[A-Za-z][A-Za-z0-9_]{0,63}$/u.test(code)) + yield* annotateRequestSpan({ "auth.failure.code": code }); + const reason = + code === undefined + ? undefined + : ((Object.hasOwn(anyEndpoint, code) ? anyEndpoint[code] : undefined) ?? + (Object.hasOwn(byEndpoint, path) && Object.hasOwn(byEndpoint[path]!, code) + ? byEndpoint[path]![code] + : undefined)); + if (reason !== undefined) return yield* recordAuthFailure(reason); + // Better Auth answers 401 without a code of its own, or with `UNAUTHORIZED`, for several + // causes: no session, a session that is no longer valid, or a signed-in caller the endpoint + // refuses, such as OAuth client management, which Cloud's `clientPrivileges` refuses to every + // session. Only a request that presented no credential at all is refused for that alone; any + // other stays `unclassified`. + if (status === 401 && (code === undefined || code === "UNAUTHORIZED")) + yield* recordNoCredential(headers); + }); diff --git a/apps/hosted/cloud/src/implementation/client-network.ts b/apps/hosted/cloud/src/implementation/client-network.ts new file mode 100644 index 0000000000..315081ebbe --- /dev/null +++ b/apps/hosted/cloud/src/implementation/client-network.ts @@ -0,0 +1,58 @@ +/** + * The network a request came from, on its server span. `client.address` is the address Cloudflare + * saw (`cf-connecting-ip`), which Alchemy gives the request and Effect's server tracer records. The + * rest comes from the request's `cf` object: its country and its autonomous system. Cloudflare sets + * both for every request it receives from the internet, and a client cannot choose them. + * + * Executor's zones are on plans whose analytics do not break traffic down by address, so these + * spans are the only record of where a request came from. + */ +import * as Cloudflare from "alchemy/Cloudflare"; +import { Effect, Option, Schema } from "effect"; +import type { HttpServerResponse } from "effect/http"; + +/** The fields read from `request.cf`; Cloudflare's other properties are ignored. */ +const RequestNetwork = Schema.Struct({ + asn: Schema.optionalKey(Schema.Number), + asOrganization: Schema.optionalKey(Schema.String), + country: Schema.optionalKey(Schema.String), +}); + +/** An autonomous system's name, such as a hosting provider's; long ones are cut. */ +const organizationLength = 128; + +/** + * The span attributes for a request's network: `geo.country.iso_code` (OpenTelemetry's name for + * the ISO 3166-1 alpha-2 code; Cloudflare also uses `T1` for Tor and `XX` for unknown), and + * `cloudflare.asn` and `cloudflare.as_organization`, which OpenTelemetry has no names for. + */ +export const clientNetworkAttributes = (cf: unknown): Record => { + const network = Schema.decodeUnknownOption(RequestNetwork)(cf); + if (Option.isNone(network)) return {}; + const { asn, asOrganization, country } = network.value; + return { + ...(country === undefined || !/^[A-Z0-9]{2}$/u.test(country) + ? {} + : { "geo.country.iso_code": country }), + ...(asn === undefined || !Number.isSafeInteger(asn) ? {} : { "cloudflare.asn": asn }), + ...(asOrganization === undefined || asOrganization === "" + ? {} + : { "cloudflare.as_organization": asOrganization.slice(0, organizationLength) }), + }; +}; + +/** + * Record the request's network on the current span. Apply it where the handler runs in the + * request's server span, beside `requestTiming`. It reads only the incoming request, with no I/O. + */ +export const recordClientNetwork = ( + handler: Effect.Effect, +) => + Effect.gen(function* () { + const request = yield* Effect.serviceOption(Cloudflare.Workers.Request); + if (Option.isSome(request)) { + const attributes = clientNetworkAttributes(Reflect.get(request.value, "cf")); + if (Object.keys(attributes).length > 0) yield* Effect.annotateCurrentSpan(attributes); + } + return yield* handler; + }); diff --git a/apps/hosted/cloud/src/implementation/organization-removal.ts b/apps/hosted/cloud/src/implementation/organization-removal.ts index 979cacd22a..c05dbed547 100644 --- a/apps/hosted/cloud/src/implementation/organization-removal.ts +++ b/apps/hosted/cloud/src/implementation/organization-removal.ts @@ -1,5 +1,6 @@ /** Cloud is the multi-organization product, so only cloud exposes removal. */ import { + audited, beginOrganizationRemoval, previewOrganizationRemoval, OrganizationId, @@ -66,7 +67,12 @@ export const organizationRemovalHandlers = HttpApiBuilder.group( // start any it could not. yield* dispatchAcceptedRemoval(start, recover, started.organization, instance); return started; - }), + }).pipe( + audited("organization.delete", { + kind: "organization", + id: (started) => started.organization, + }), + ), ); }), ); diff --git a/apps/hosted/cloud/src/implementation/v1-import/run.ts b/apps/hosted/cloud/src/implementation/v1-import/run.ts index 3dcd1d9bc8..e365742b8e 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/run.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/run.ts @@ -13,6 +13,7 @@ * and the step's tool listings, released with the step. */ import { + recordSecurityAudit, HostedExecutor, organizationOwner, type McpConnectionStore, @@ -2377,6 +2378,14 @@ const verify = ( for (const account of accounts) if (!(yield* context.executor[OAuthGrantImport].credentialsReadable(AccountId.make(account)))) unreadable++; + // One record for the whole read: it decrypts every imported account's credentials at once. + yield* recordSecurityAudit({ + action: "credential.read", + outcome: unreadable === 0 ? "success" : "failure", + target: { kind: "organization", id: context.target.id }, + organization: context.target.id, + detail: { purpose: "import_verification", accounts: String(accounts.size) }, + }); if (unreadable > 0) problems.push(`${unreadable} imported accounts' credentials do not decrypt with v2's key.`); diff --git a/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts b/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts index 8b004fe72c..1eed7919a5 100644 --- a/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts +++ b/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts @@ -22,6 +22,9 @@ import { OrganizationId, OrganizationRole, organizationOwner, + recordAuthFailure, + recordCredential, + type AuthFailureReason, type OrganizationReference, } from "@executor-js/hosted-server"; import { ApprovalMode, GrantId, type GrantPolicy } from "@executor-js/mcp-auth"; @@ -83,15 +86,18 @@ const grantId = (input: { ); const unauthorized = new McpUnauthorized(); +/** Refuse a forwarded bearer, recording why on the request's server span. */ +const rejected = (reason: AuthFailureReason) => + Effect.andThen(recordAuthFailure(reason), Effect.fail(unauthorized)); /** The claims of a bearer v1 signed with `secret` that has not expired, or why not. */ const verify = (secret: Redacted.Redacted, token: string) => Effect.gen(function* () { const [, payload, signature, ...rest] = token.split("."); if (payload === undefined || signature === undefined || rest.length > 0) - return yield* unauthorized; + return yield* rejected("malformed"); const mac = Base64Url.decode(signature); - if (Result.isFailure(mac)) return yield* unauthorized; + if (Result.isFailure(mac)) return yield* rejected("malformed"); const valid = yield* Effect.tryPromise(async () => { const key = await crypto.subtle.importKey( "raw", @@ -107,20 +113,22 @@ const verify = (secret: Redacted.Redacted, token: string) => new TextEncoder().encode(`${prefix}${payload}`), ); }).pipe(Effect.orDie); - if (!valid) return yield* unauthorized; + // Not signed with v1's secret: no bearer v1 issued. + if (!valid) return yield* rejected("unknown"); const claims = yield* Base64Url.decodeString(payload).pipe( Result.match({ - onFailure: () => Effect.fail(unauthorized), + onFailure: () => rejected("malformed"), onSuccess: (json) => Schema.decodeUnknownEffect(Schema.fromJsonString(Claims))(json).pipe( - Effect.mapError(() => unauthorized), + Effect.catch(() => rejected("malformed")), ), }), ); const now = Math.floor((yield* Clock.currentTimeMillis) / 1000); - if (claims.exp < now || claims.exp > now + lifetimeSeconds) return yield* unauthorized; + if (claims.exp < now) return yield* rejected("expired"); + if (claims.exp > now + lifetimeSeconds) return yield* rejected("malformed"); if ((claims.toolkit === undefined) !== (claims.connection === undefined)) - return yield* unauthorized; + return yield* rejected("malformed"); return claims; }); @@ -255,31 +263,42 @@ export const v1ForwardAccess = ( Effect.gen(function* () { const token = bearer(request.headers); // A deployment without the secret accepts no forwarded request. - if (token === undefined || Option.isNone(secret)) return yield* unauthorized; + yield* recordCredential({ kind: "v1_forward" }); + if (token === undefined) return yield* rejected("malformed"); + // A deployment without v1's secret recognizes no forwarded bearer. + if (Option.isNone(secret)) return yield* rejected("unknown"); const claims = yield* verify(secret.value, token); const row = yield* load(yield* SqlClient.SqlClient, claims); const forbidden = new McpForbidden({ reason: "membership" }); - if (row.userId === null || row.organization === null) return yield* forbidden; + // v1 vouched for its signed claims; the import maps their account to this user. + if (row.userId !== null) yield* recordCredential({ kind: "v1_forward", user: row.userId }); + if (row.userId === null || row.organization === null) + return yield* Effect.andThen(recordAuthFailure("not_member"), Effect.fail(forbidden)); // v1 sends its requests to the bare URL; one that names an organization must name this one. if ( request.organization !== undefined && request.organization !== row.organization && request.organization !== row.slug ) - return yield* new McpForbidden({ reason: "organization_mismatch" }); + return yield* Effect.andThen( + recordAuthFailure("organization_mismatch"), + Effect.fail(new McpForbidden({ reason: "organization_mismatch" })), + ); // A toolkit runs under the caller's copy, the scoped connection its import made for them. v1 // names it by the id the import gives a copy first, generation 0's; one the opt-in import made // keeps its earlier id, and one made again after a rollback has a later generation's. const copy = row.toolkitConnection; if (claims.toolkit !== undefined) { - if (copy === null) return yield* forbidden; + if (copy === null) + return yield* Effect.andThen(recordAuthFailure("wrong_resource"), Effect.fail(forbidden)); const derived = yield* toolkitCopyConnectionId( row.organization, claims.toolkit, claims.account, 0, ); - if (claims.connection !== copy && claims.connection !== derived) return yield* forbidden; + if (claims.connection !== copy && claims.connection !== derived) + return yield* Effect.andThen(recordAuthFailure("wrong_resource"), Effect.fail(forbidden)); } return yield* grantedAccess( { @@ -300,6 +319,9 @@ export const v1ForwardAccess = ( address: claims.connection, }, forbidden, + ).pipe( + // The user is no longer a member there, or the toolkit's connection is no longer theirs. + Effect.tapError(() => recordAuthFailure("not_member")), ); }).pipe(outage, Effect.withSpan("auth.v1Forward")); diff --git a/apps/hosted/cloud/src/implementation/v1-session-handoff.ts b/apps/hosted/cloud/src/implementation/v1-session-handoff.ts index 30dd59ae0c..68250c1070 100644 --- a/apps/hosted/cloud/src/implementation/v1-session-handoff.ts +++ b/apps/hosted/cloud/src/implementation/v1-session-handoff.ts @@ -14,7 +14,11 @@ * organization the v1 organization was imported as, and starts an ordinary Better Auth session. * Every refusal opens the v2 sign-in page, returning to the organization. Nothing here reads v1. */ -import { AuthenticationUnavailable } from "@executor-js/hosted-server"; +import { + AuthenticationUnavailable, + auditFailureCodes, + recordSecurityAudit, +} from "@executor-js/hosted-server"; import { OrganizationSlug } from "@executor-js/hosted-server/organization"; import type { BetterAuthPlugin } from "better-auth"; import { APIError, createAuthEndpoint } from "better-auth/api"; @@ -75,6 +79,7 @@ export class V1HandoffRefused extends Schema.TaggedError()("V1 }) {} const refuse = (reason: V1HandoffRefusal) => new V1HandoffRefused({ reason }); +const refusalCode = auditFailureCodes(V1HandoffRefusal.literals); /** Better Auth could not start the session. */ export class V1HandoffSessionFailed extends Schema.TaggedError()( @@ -266,6 +271,14 @@ export const v1SessionHandoff = (services: V1HandoffServices) => }).pipe( Effect.catchTag("V1HandoffRefused", (error) => Effect.logInfo("v1 session handoff refused", { reason: error.reason }).pipe( + Effect.andThen( + recordSecurityAudit({ + action: "sign_in", + outcome: "failure", + failure: refusalCode(error.reason), + detail: { method: "v1_handoff" }, + }), + ), Effect.as(signIn(token)), ), ), diff --git a/apps/hosted/cloud/src/infrastructure/app-sessions.ts b/apps/hosted/cloud/src/infrastructure/app-sessions.ts index 7c6928147e..fe2cea7072 100644 --- a/apps/hosted/cloud/src/infrastructure/app-sessions.ts +++ b/apps/hosted/cloud/src/infrastructure/app-sessions.ts @@ -26,6 +26,15 @@ export const organizationTables = { }, } satisfies BetterAuthPlugin; +/** + * The admin plugin's session column naming the admin whose impersonation created a session. An + * app page's request span names that admin; the dashboard's plugin owns the column. + */ +const impersonationColumn = { + id: "admin-impersonation", + schema: { session: { fields: { impersonatedBy: { type: "string", required: false } } } }, +} satisfies BetterAuthPlugin; + /** * App pages only keep verification records and read sessions, members and * organizations. The dashboard's full Better Auth instance writes the same rows: @@ -36,7 +45,7 @@ export const cloudAppSessions = Effect.gen(function* () { const database = yield* AuthDatabase; const options = { database: database.options, - plugins: [organizationTables], + plugins: [organizationTables, impersonationColumn], // The dashboard cleans expired records during sign-in. App reads check expiry themselves. verification: { disableCleanup: true }, // As in the dashboard: the deployment migration validates the schema, not requests. diff --git a/apps/hosted/cloud/src/infrastructure/auth.ts b/apps/hosted/cloud/src/infrastructure/auth.ts index 3aa33636f3..ef37bab752 100644 --- a/apps/hosted/cloud/src/infrastructure/auth.ts +++ b/apps/hosted/cloud/src/infrastructure/auth.ts @@ -3,7 +3,7 @@ import { HostedAppSessions } from "@executor-js/hosted-server/app-ui"; import { authObservability } from "../implementation/auth-observability.ts"; import { AsyncLocalStorage } from "node:async_hooks"; import { APIError } from "better-auth/api"; -import { OrganizationId } from "@executor-js/hosted-server"; +import { OrganizationId, recordCredential, recordSecurityAudit } from "@executor-js/hosted-server"; import { BillingMeter } from "../contracts/billing-meter.ts"; import { billingLive } from "../implementation/billing.ts"; import { @@ -44,10 +44,12 @@ import { HttpBody, HttpServerRequest, HttpServerResponse } from "effect/http"; import type { SendAuthEmail } from "../contracts/email.ts"; import { cloudSecrets, v1McpForwardSecret, v1SessionHandoffSecret } from "./secrets.ts"; import { V1HandoffSessionFailed, v1SessionHandoff } from "../implementation/v1-session-handoff.ts"; +import { recordAuthRefusal } from "../implementation/auth-refusals.ts"; import { cloudAuthRateLimit } from "./stage.ts"; import { AuthDatabase, appSessionsPerCall, boundAuthAdapter } from "./auth-database.ts"; import { invocationSql } from "./invocation-database.ts"; import { mcpAuthentication } from "./mcp-auth.ts"; +import { authAudit } from "../implementation/auth-audit.ts"; /** Bind during initialization; database calls capture the current invocation only. */ export const cloudAuth = (send: SendAuthEmail, onboarding: typeof Onboarding.Service) => @@ -155,7 +157,24 @@ export const cloudAuth = (send: SendAuthEmail, onboarding: typeof Onboarding.Ser const makeInstance = (secret: string) => betterAuth({ ...options, - plugins: [...options.plugins, observation.plugin], + // After the analytics plugin: Better Auth's endpoint middleware does not leave its + // session on the after-hook context, and the analytics hook's read of it does. The audit + // reads no session itself. Telemetry never fails a request, so a record that cannot be + // written is dropped. + plugins: [ + ...options.plugins, + authAudit((event, session) => + runCallback( + Effect.andThen( + session === undefined + ? Effect.void + : recordCredential({ kind: "session", ...session }), + recordSecurityAudit(event), + ), + ).catch(() => undefined), + ), + observation.plugin, + ], database: database.options, secret, // Cookies use hostnames, not ports; cloud dev must not replace self-host sessions. @@ -291,7 +310,8 @@ export const cloudAuth = (send: SendAuthEmail, onboarding: typeof Onboarding.Ser const web = yield* HttpServerRequest.toWeb(request).pipe(Effect.orDie); const [instance, bind] = yield* bound; routes ??= routeTemplates(authEndpointTemplates(instance.api)); - yield* routes.record(new URL(web.url).pathname); + const pathname = new URL(web.url).pathname; + yield* routes.record(pathname); const context = yield* Effect.context< RuntimeContext | HttpServerRequest.HttpServerRequest | Scope.Scope >(); @@ -299,10 +319,14 @@ export const cloudAuth = (send: SendAuthEmail, onboarding: typeof Onboarding.Ser const response = yield* Effect.promise((signal) => callbacks.run({ context, signal }, () => bind(() => instance.handler(web))), ); - if (response.body === null) return HttpServerResponse.fromWeb(response); + if (response.body === null) { + yield* recordAuthRefusal(response.status, pathname, new Uint8Array(), web.headers); + return HttpServerResponse.fromWeb(response); + } // Better Auth answers with complete JSON. Sent as a stream, its last byte would wait for // the request's cleanup, including Better Auth queries the request left running. const body = new Uint8Array(yield* Effect.promise(() => response.arrayBuffer())); + yield* recordAuthRefusal(response.status, pathname, body, web.headers); return HttpServerResponse.setBody( HttpServerResponse.fromWeb(response), HttpBody.uint8Array(body, response.headers.get("content-type") ?? undefined), diff --git a/apps/hosted/cloud/src/infrastructure/mcp-auth.ts b/apps/hosted/cloud/src/infrastructure/mcp-auth.ts index 16c9824e6f..019e633820 100644 --- a/apps/hosted/cloud/src/infrastructure/mcp-auth.ts +++ b/apps/hosted/cloud/src/infrastructure/mcp-auth.ts @@ -3,7 +3,7 @@ import { AuthenticationUnavailable, McpAuthentication, authOptions, - callerSpanAttributes, + recordCaller, type HostedOAuthOrigins, mcpBrowserGrantError, mcpBearerAccess, @@ -98,9 +98,7 @@ export const mcpAuthentication = ( ? v1ForwardAccess(v1Forward, { headers, mode, organization }) : mcpBearerAccess(origins, { headers, mode, organization }), ).pipe( - Effect.tap(({ access, userId }) => - Effect.annotateCurrentSpan(callerSpanAttributes(access.organization, userId)), - ), + Effect.tap(({ access, userId }) => recordCaller(access.organization, userId)), Effect.withSpan("auth.authenticate"), ), browserGrant: (headers, id) => diff --git a/apps/hosted/cloud/src/main.ts b/apps/hosted/cloud/src/main.ts index fbbf23cf79..fb456308a7 100644 --- a/apps/hosted/cloud/src/main.ts +++ b/apps/hosted/cloud/src/main.ts @@ -130,8 +130,13 @@ import { hasSessionCookie } from "./contracts/browser.ts"; import { clientMetadataBinding, cloudClientMetadata } from "./infrastructure/client-metadata.ts"; import { appDataSupervisors } from "./infrastructure/app-data.ts"; import { cloudDevelopment } from "./contracts/development.ts"; -import { requestServices } from "@executor-js/hosted-server"; -import { recordRequestRejections, requestTiming } from "@executor-js/telemetry/http"; +import { recordUnclassifiedRefusals, requestServices } from "@executor-js/hosted-server"; +import { + toRequestHttpEffect, + recordRequestRejections, + requestTiming, +} from "@executor-js/telemetry/http"; +import { recordClientNetwork } from "./implementation/client-network.ts"; import { appCatalogBinding } from "./infrastructure/app-catalog.ts"; import { Api } from "./infrastructure/api-worker.ts"; @@ -479,7 +484,7 @@ export default Api.make( // for the isolate. Building is synchronous: no other request can see a partial build. const buildApi = api.pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, memoMap), ); const apiServices = yield* Effect.context>(); @@ -669,7 +674,7 @@ export default Api.make( // Routes are immutable per isolate; requestServices keeps live auth resources in each event. const handle = yield* routes.pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, memoMap), ); // Each role host has its own router, built from the same route layers, so it can serve no @@ -681,7 +686,7 @@ export default Api.make( HttpRouter.add("GET", "/.well-known/openai-apps-challenge", openAiAppsChallenge), ).pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, memoMap), ); const apiHost = yield* Layer.mergeAll( @@ -695,7 +700,7 @@ export default Api.make( withGitAccess(registryRoutes), ).pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, memoMap), ); // The paths v1's edge forwards from `executor.sh`. Issuer metadata is served here, never @@ -720,7 +725,7 @@ export default Api.make( publishedSkillRoutes(authoring), ).pipe( Layer.provide(HttpServer.layerServices), - HttpRouter.toHttpEffect, + toRequestHttpEffect, Effect.provideService(Layer.CurrentMemoMap, memoMap), ); const roleHosts = withRoleHosts( @@ -746,6 +751,8 @@ export default Api.make( recordRequestRejections, reportErrors, requestTiming, + recordClientNetwork, + recordUnclassifiedRefusals, // Server-rendered pages read the API through this complete pipeline, in-process. withHostPipeline, lifetime.http, diff --git a/apps/hosted/server/src/contracts/api.ts b/apps/hosted/server/src/contracts/api.ts index 3b97c1d7f3..3346c8e8c4 100644 --- a/apps/hosted/server/src/contracts/api.ts +++ b/apps/hosted/server/src/contracts/api.ts @@ -14,9 +14,9 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api import { AuthenticationUnavailable, BrowserSessionOnly, - Principal, RequireUser, Unauthorized, + Viewer, } from "./auth.ts"; import { HostedOrganization, @@ -207,7 +207,7 @@ export const HostedApi = HttpApi.make("executor-hosted") .add(HttpApiGroup.make("health").add(HttpApiEndpoint.get("get", "/health", { success: Health }))) .add( HttpApiGroup.make("viewer") - .add(HttpApiEndpoint.get("get", "/api/viewer", { success: Principal })) + .add(HttpApiEndpoint.get("get", "/api/viewer", { success: Viewer })) .middleware(RequireUser), ) .add( diff --git a/apps/hosted/server/src/contracts/app-ui.ts b/apps/hosted/server/src/contracts/app-ui.ts index f07dedb6ca..6a4891dc19 100644 --- a/apps/hosted/server/src/contracts/app-ui.ts +++ b/apps/hosted/server/src/contracts/app-ui.ts @@ -163,11 +163,17 @@ export class HostedAppSessions extends Context.Service< }, UiUnauthorized | UiForbidden | UiFailed >; - /** Authenticate the scoped token and live parent session; serving policy checks membership. */ + /** + * Authenticate the scoped token and live parent session; serving policy checks membership. + * `impersonatedBy` names the admin whose impersonation created the parent dashboard session. + */ readonly current: ( target: AppUiTarget, token: typeof AppSignInCode.Type, - ) => Effect.Effect<{ readonly userId: string }, UiUnauthorized | UiForbidden | UiFailed>; + ) => Effect.Effect< + { readonly userId: string; readonly impersonatedBy?: string }, + UiUnauthorized | UiForbidden | UiFailed + >; } >()("hosted/AppSessions") {} diff --git a/apps/hosted/server/src/contracts/auth.ts b/apps/hosted/server/src/contracts/auth.ts index 8dbbcb09dd..54396c6441 100644 --- a/apps/hosted/server/src/contracts/auth.ts +++ b/apps/hosted/server/src/contracts/auth.ts @@ -8,12 +8,23 @@ import { Context, Effect, Schema } from "effect"; import { HttpApiMiddleware } from "effect/http-api"; import type { OrganizationId, OrganizationRole, OrganizationForbidden } from "./organization.ts"; -/** A hosted login identity, separate from SDK provider accounts and owners. */ -export const Principal = Schema.Struct({ +/** What `/api/viewer` returns about the signed-in user. */ +export const Viewer = Schema.Struct({ userId: Schema.String.pipe(Schema.brand("HostedUserId")), sessionId: Schema.String.pipe(Schema.brand("HostedSessionId")), name: Schema.String, }); +export type Viewer = typeof Viewer.Type; + +/** + * A hosted login identity, separate from SDK provider accounts and owners. `impersonatedBy` is + * internal: request spans record it, and `/api/viewer` returns only the `Viewer` fields. + */ +export const Principal = Schema.Struct({ + ...Viewer.fields, + /** The admin whose impersonation created this session, as Better Auth stored it on the row. */ + impersonatedBy: Schema.optionalKey(Viewer.fields.userId), +}); export type Principal = typeof Principal.Type; /** No valid hosted session was supplied. */ @@ -148,14 +159,79 @@ export const CurrentUserId = CurrentActor; /** * What a request's spans record about its caller once the organization is known to be the * caller's own: the organization's opaque ID and the caller's Better Auth user ID, never an - * email, a name or a slug. Both are recorded together, so a span names a user only where it names - * that user's organization, and a request refused for another organization names neither. + * email, a name or a slug. A request refused for another organization never names that one. Its + * request span still names the verified caller's user ID and credential (`AuthCredentialKind`), + * so refusals can be attributed to who made them. */ export const callerSpanAttributes = (organization: string, user: string) => ({ "executor.organization.id": organization, "executor.user.id": user, }); +/** + * The kind of credential a request presented, as its request span records it + * (`auth.credential.kind`): a browser session cookie, an OAuth access token, a personal access + * token (API key), a request v1 forwarded with its signed bearer, an app page's session, or none. + */ +export const AuthCredentialKind = Schema.Literals([ + "session", + "oauth_access_token", + "api_key", + "v1_forward", + "app_session", + "none", +]); +export type AuthCredentialKind = typeof AuthCredentialKind.Type; + +/** + * Why a request was refused with 401 or 403, as its request span records it + * (`auth.failure.reason`). Each value is decided where the check fails, from the check's own state: + * + * - `missing`: no credential was presented. + * - `malformed`: a credential or organization header was presented in a form no check accepts. + * - `unknown`: no stored credential matches it (a token, key, session or app session). A session + * that expired or was signed out is no longer stored, so it is `unknown` too. + * - `expired`: the stored credential, or the session it belongs to, has expired. + * - `revoked`: the credential, its grant, client, connection or user was revoked, disabled or + * removed. + * - `wrong_resource`: a live credential issued for another resource, audience, connection or app. + * - `attempts_exceeded`: too many wrong one-time codes; the code accepts no more attempts. + * - `origin_mismatch`: a browser request from another origin. + * - `bearer_not_allowed`: a browser-only operation was sent an API credential. + * - `organization_required`: an unpinned key named no organization where one is needed. + * - `organization_mismatch`: the request names a different organization than its credential's. + * - `organization_not_found`: the organization the request names does not exist, is ambiguous, or + * is being removed. + * - `not_member`: the verified caller is not a member of the organization. + * - `insufficient_scope`: the credential's grant does not permit this action, app or tool. + * - `insufficient_role`: the caller's role does not permit this administrative action. + * - `resource_denied`: a member was refused by a check on the resource itself, such as an app or + * account not shared with them. + * - `app_not_found`: an app page's host or app does not exist. + * - `unclassified`: a 401 or 403 that no check above recorded; it marks a gap to classify. + */ +export const AuthFailureReason = Schema.Literals([ + "missing", + "malformed", + "unknown", + "expired", + "revoked", + "wrong_resource", + "attempts_exceeded", + "origin_mismatch", + "bearer_not_allowed", + "organization_required", + "organization_mismatch", + "organization_not_found", + "not_member", + "insufficient_scope", + "insufficient_role", + "resource_denied", + "app_not_found", + "unclassified", +]); +export type AuthFailureReason = typeof AuthFailureReason.Type; + /** Request-local identity supplied only after successful authentication. */ export class CurrentPrincipal extends Context.Service()( "hosted/CurrentPrincipal", diff --git a/apps/hosted/server/src/contracts/mcp.ts b/apps/hosted/server/src/contracts/mcp.ts index 416fd3ded1..7b8bf1fe4d 100644 --- a/apps/hosted/server/src/contracts/mcp.ts +++ b/apps/hosted/server/src/contracts/mcp.ts @@ -106,20 +106,26 @@ export interface McpConnectionStore { owner: ConnectionOwner, input: { readonly id: ConnectionId; readonly name: string; readonly policy: ConnectionPolicy }, ) => Effect.Effect; - /** Revoke the connection, every grant issued through it, and their tokens. */ + /** + * Revoke the connection, every grant issued through it, and their tokens. Succeeds with the + * stored connection's ID once every step is stored. + */ readonly revoke: ( owner: ConnectionOwner, id: ConnectionId, - ) => Effect.Effect; + ) => Effect.Effect; /** The owner's authorized MCP and API clients, one per live grant. */ readonly agents: ( owner: ConnectionOwner, ) => Effect.Effect; - /** Revoke one of the owner's grants and delete its access and refresh tokens. */ + /** + * Revoke one of the owner's grants and delete its access and refresh tokens. Succeeds with the + * stored grant's ID once every step is stored. + */ readonly revokeAgent: ( owner: ConnectionOwner, id: GrantId, - ) => Effect.Effect; + ) => Effect.Effect; } /** Better Auth owns grant validation; each host supplies its native request lifetime. */ diff --git a/apps/hosted/server/src/contracts/security-audit.ts b/apps/hosted/server/src/contracts/security-audit.ts new file mode 100644 index 0000000000..eb786a5797 --- /dev/null +++ b/apps/hosted/server/src/contracts/security-audit.ts @@ -0,0 +1,79 @@ +/** + * Security audit events: one log record named `security.audit` for each security-relevant action, + * with who did it, from where, to what and whether it succeeded. They are telemetry, delivered + * best effort like every other log, not a durable audit trail. + */ +import { Schema } from "effect"; + +/** What happened. The vocabulary is closed so that a query can name every action. */ +export const SecurityAuditAction = Schema.Literals([ + "sign_in", + "impersonation.start", + "impersonation.stop", + "otp.verify", + "session.revoke", + "api_key.create", + "api_key.delete", + "member.role_change", + "member.remove", + "invitation.create", + "invitation.accept", + "invitation.cancel", + "oauth_client.register", + "oauth.consent", + "provider_oauth_client.create", + "provider_oauth_client.delete", + "mcp_agent.revoke", + "mcp_connection.revoke", + "organization.delete", + "credential.read", +]); +export type SecurityAuditAction = typeof SecurityAuditAction.Type; + +/** + * How an action ended: + * + * - `success`: the change it makes was stored, or the read it audits happened. + * - `failure`: it was refused or failed before changing anything it audits. + * - `interrupted`: the request ended before the action's outcome was known; a change it started + * may still have been stored. + * - `unconfirmed`: the action answered success, but the change it reports was not observed where + * it is stored, such as a sign-out whose session deletion failed or found nothing to delete. + */ +export type SecurityAuditOutcome = "success" | "failure" | "interrupted" | "unconfirmed"; + +const AuditFailureCode = Schema.String.pipe(Schema.brand("SecurityAuditFailure")); +/** A failure code from a recorder's closed set, or `other`; made only by `auditFailureCodes`. */ +export type SecurityAuditFailure = typeof AuditFailureCode.Type; + +/** + * The failure codes a recorder may write: each of `codes`, and `other` for any code outside them. + * A failure code can come from a request or a redirect a client wrote, so it is never recorded + * unless it is one of the codes the recorder knows its endpoints answer with. + */ +export const auditFailureCodes = (codes: Iterable) => { + const known = new Set(codes); + return (code: string | undefined): SecurityAuditFailure | undefined => + code === undefined ? undefined : AuditFailureCode.make(known.has(code) ? code : "other"); +}; + +/** + * One audited action. Every value is an opaque ID, a closed label or a known error code; never a + * credential, an email address, a name or a request body. A target's ID, the user and the + * organization come only from stored records the action was authorized against, or from what it + * stored, never from what the request asked for: an ID a caller sends can be any text. The + * caller's user and organization default to the request's verified caller and membership. + */ +export interface SecurityAuditEvent { + readonly action: SecurityAuditAction; + readonly outcome: SecurityAuditOutcome; + /** The kind and opaque ID of what the action applied to, such as `api_key` and the key's ID. */ + readonly target?: { readonly kind: string; readonly id?: string | undefined } | undefined; + /** A known error code for a failure, such as Better Auth's `INVALID_OTP`. */ + readonly failure?: SecurityAuditFailure | undefined; + /** How, from a closed set: a sign-in's method, a credential read's purpose, a new role. */ + readonly detail?: Readonly> | undefined; + readonly user?: string | undefined; + /** An organization the caller's membership, or the stored change, places the action in. */ + readonly organization?: string | undefined; +} diff --git a/apps/hosted/server/src/implementation/access.ts b/apps/hosted/server/src/implementation/access.ts index 8a14596a70..83d4845586 100644 --- a/apps/hosted/server/src/implementation/access.ts +++ b/apps/hosted/server/src/implementation/access.ts @@ -30,6 +30,7 @@ import type { ToolAuthorization } from "./authorization.ts"; import { Context, Effect, type Exit, Result } from "effect"; import { HttpServerRequest } from "effect/http"; import { CurrentUserId, Forbidden } from "../contracts/auth.ts"; +import { refuse } from "./auth-telemetry.ts"; import { appAccountReads, appLinkedAccounts, @@ -46,18 +47,21 @@ import { /** A person's own decisions come from their signed-in browser, never from an API credential. */ export const browserOnly = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - if (request.headers.authorization !== undefined) return yield* new Forbidden(); + if (request.headers.authorization !== undefined) + return yield* refuse("bearer_not_allowed", new Forbidden()); }); /** Membership was checked by middleware; administrative actions require the current role. */ export const requireOrganizationAdmin = Effect.gen(function* () { const organization = yield* CurrentOrganization; - if (organization.role === "member") return yield* new OrganizationForbidden(); + if (organization.role === "member") + return yield* refuse("insufficient_role", new OrganizationForbidden()); return organization; }); /** Only an owner may remove the organization itself. */ export const requireOrganizationOwner = Effect.gen(function* () { const organization = yield* CurrentOrganization; - if (organization.role !== "owner") return yield* new OrganizationForbidden(); + if (organization.role !== "owner") + return yield* refuse("insufficient_role", new OrganizationForbidden()); return organization; }); /** Server-derived owners are the only owners used by hosted HTTP handlers. */ diff --git a/apps/hosted/server/src/implementation/accounts.ts b/apps/hosted/server/src/implementation/accounts.ts index 61b2efdcc4..18eebc7755 100644 --- a/apps/hosted/server/src/implementation/accounts.ts +++ b/apps/hosted/server/src/implementation/accounts.ts @@ -1,4 +1,5 @@ import { accountOAuthRedirectUri } from "./auth.ts"; +import { audited } from "./security-audit.ts"; import { ScheduleWakeup } from "../contracts/schedules.ts"; import { CurrentAuthorization } from "../contracts/authorization.ts"; import { permittedAppIds } from "@executor-js/authorization"; @@ -118,11 +119,10 @@ export const checkAccount = Effect.gen(function* () { const { owner, account } = yield* AccountGrants.use; const executor = yield* Effect.flatten(HostedExecutor); const apps = yield* linkedApps(owner, account); - const health = yield* executor.accounts.check({ - owner, - account, - apps: apps.map((app) => app.id), - }); + // A check decrypts the account's credentials and may renew them, on the caller's request. + const health = yield* executor.accounts + .check({ owner, account, apps: apps.map((app) => app.id) }) + .pipe(audited("credential.read", { kind: "account", id: () => account }, { purpose: "check" })); return visibleHealth(health, apps); }); /** diff --git a/apps/hosted/server/src/implementation/api.ts b/apps/hosted/server/src/implementation/api.ts index 98666a8c28..9dccbb59a6 100644 --- a/apps/hosted/server/src/implementation/api.ts +++ b/apps/hosted/server/src/implementation/api.ts @@ -96,7 +96,8 @@ export const hostedHandlers = Layer.mergeAll( HttpApiBuilder.group(HostedApi, "viewer", (handlers) => handlers.handle("get", () => Effect.gen(function* () { - return yield* CurrentPrincipal; + const { userId, sessionId, name } = yield* CurrentPrincipal; + return { userId, sessionId, name }; }), ), ), diff --git a/apps/hosted/server/src/implementation/app-sessions.ts b/apps/hosted/server/src/implementation/app-sessions.ts index db417e27dc..676e741fec 100644 --- a/apps/hosted/server/src/implementation/app-sessions.ts +++ b/apps/hosted/server/src/implementation/app-sessions.ts @@ -3,6 +3,7 @@ import { resolveOrganizationReference } from "./organization-reference.ts"; import type { AuthContext } from "@better-auth/core"; import { AppSignInId } from "apps/ui/auth/contracts"; import { UiFailed, UiForbidden, UiUnauthorized } from "apps/ui/contracts"; +import { refuse } from "./auth-telemetry.ts"; import { Clock, Effect, Redacted, Result, Schema } from "effect"; import { AppUiAttempt, @@ -26,7 +27,12 @@ const sameTarget = (a: AppUiTarget, b: AppUiTarget) => a.organization === b.organization && a.origin === b.origin && a.slug === b.slug; -const Parent = Schema.Struct({ userId: Principal.fields.userId, expiresAt: Schema.Date }); +/** Better Auth stores `impersonatedBy` as null on a session no admin's impersonation created. */ +const Parent = Schema.Struct({ + userId: Principal.fields.userId, + expiresAt: Schema.Date, + impersonatedBy: Schema.optional(Schema.NullOr(Principal.fields.userId)), +}); const Organization = Schema.Struct({ id: OrganizationId, slug: OrganizationSlug }); const Membership = Schema.Struct({ role: OrganizationRole }); const Stored = Schema.Struct({ value: Schema.String, expiresAt: Schema.Date }); @@ -90,12 +96,12 @@ export const hostedAppSessions = ( ? context.internalAdapter.consumeVerificationValue(identifier) : context.internalAdapter.findVerificationValue(identifier), ); - if (row === null) return yield* new UiUnauthorized(); + if (row === null) return yield* refuse("unknown", new UiUnauthorized()); const stored = yield* Schema.decodeUnknownEffect(Stored)(row).pipe( Effect.mapError(unavailable), ); if (stored.expiresAt.getTime() <= (yield* Clock.currentTimeMillis)) - return yield* new UiUnauthorized(); + return yield* refuse("expired", new UiUnauthorized()); return yield* Schema.decodeUnknownEffect(recordJson)(stored.value).pipe( Effect.mapError(unavailable), ); @@ -106,18 +112,17 @@ export const hostedAppSessions = ( context.adapter.findOne({ model: "session", where: [{ field: "id", value: id }], - select: ["userId", "expiresAt"], + select: ["userId", "expiresAt", "impersonatedBy"], }), ); - if (row === null) return yield* new UiUnauthorized(); + // The dashboard session the app session came from has ended: signed out or revoked. + if (row === null) return yield* refuse("revoked", new UiUnauthorized()); const session = yield* Schema.decodeUnknownEffect(Parent)(row).pipe( Effect.mapError(unavailable), ); - if ( - session.userId !== user || - session.expiresAt.getTime() <= (yield* Clock.currentTimeMillis) - ) - return yield* new UiUnauthorized(); + if (session.userId !== user) return yield* refuse("wrong_resource", new UiUnauthorized()); + if (session.expiresAt.getTime() <= (yield* Clock.currentTimeMillis)) + return yield* refuse("expired", new UiUnauthorized()); return session; }); const membership = ( @@ -135,7 +140,7 @@ export const hostedAppSessions = ( select: ["role"], }), ); - if (row === null) return yield* new UiForbidden(); + if (row === null) return yield* refuse("not_member", new UiForbidden()); const member = yield* Schema.decodeUnknownEffect(Membership)(row).pipe( Effect.mapError(() => new UiForbidden()), ); @@ -158,7 +163,8 @@ export const hostedAppSessions = ( ? { id: yield* resolveOrganizationReference(context.adapter, find.reference).pipe( Effect.catchTags({ - OrganizationForbidden: () => Effect.fail(new UiForbidden()), + OrganizationForbidden: () => + refuse("organization_not_found", new UiForbidden()), AuthenticationUnavailable: () => Effect.fail(unavailable()), }), ), @@ -175,7 +181,7 @@ export const hostedAppSessions = ( select: ["id", "slug"], }), ); - if (row === null) return yield* new UiForbidden(); + if (row === null) return yield* refuse("organization_not_found", new UiForbidden()); return yield* Schema.decodeUnknownEffect(Organization)(row).pipe( Effect.mapError(unavailable), ); @@ -253,11 +259,13 @@ export const hostedAppSessions = ( Effect.gen(function* () { const session = yield* read(key("session", yield* digest(token))); if (!Schema.is(AppUiSession)(session) || !sameTarget(session.target, target)) - return yield* new UiUnauthorized(); - yield* parent(session.parent, session.user); + return yield* refuse("wrong_resource", new UiUnauthorized()); + const login = yield* parent(session.parent, session.user); // The serving host checks current membership together with app/account // permissions. A second lookup here adds no authority to that decision. - return { userId: session.user }; + return typeof login.impersonatedBy === "string" + ? { userId: session.user, impersonatedBy: login.impersonatedBy } + : { userId: session.user }; }), }); }; diff --git a/apps/hosted/server/src/implementation/app-ui.ts b/apps/hosted/server/src/implementation/app-ui.ts index 8312bcfba4..961ed03b3c 100644 --- a/apps/hosted/server/src/implementation/app-ui.ts +++ b/apps/hosted/server/src/implementation/app-ui.ts @@ -3,6 +3,7 @@ import { CurrentUsage, recordUsage } from "../contracts/product-analytics.ts"; import { fullAuthority } from "@executor-js/authorization"; import { GroupDatabase } from "../contracts/groups.ts"; import { requireAppAccess, requireAppUse } from "./resource-policy.ts"; +import { recordAuthFailure, recordCaller, recordCredential, refuse } from "./auth-telemetry.ts"; /** Hosted policy around the shared app browser protocol and retained asset renderer. */ import { AccountRequired, @@ -107,7 +108,7 @@ export const hostedAppUi = ( const requestOrigin = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const host = addresses.fromHost(request.headers.host); - if (Option.isNone(host)) return yield* new UiForbidden(); + if (Option.isNone(host)) return yield* refuse("app_not_found", new UiForbidden()); const safe = request.method === "GET" || request.method === "HEAD"; if ( (!safe && request.headers.origin !== host.value.origin) || @@ -115,7 +116,7 @@ export const hostedAppUi = ( (request.headers["sec-fetch-site"] === "cross-site" && !(request.method === "GET" && request.headers["sec-fetch-mode"] === "navigate")) ) - return yield* new UiForbidden(); + return yield* refuse("origin_mismatch", new UiForbidden()); return { request, host: host.value }; }); const target = Effect.gen(function* () { @@ -127,7 +128,7 @@ export const hostedAppUi = ( .list({ owner: organizationOwner(organization.id), ...host.find }) .pipe(Effect.mapError(unavailable)); const app = matches[0]; - if (app === undefined) return yield* new UiForbidden(); + if (app === undefined) return yield* refuse("app_not_found", new UiForbidden()); return { request, app, @@ -181,15 +182,27 @@ export const hostedAppUi = ( const token = Schema.decodeUnknownOption(AppSignInCode)( resolved.request.cookies[sessionCookie(resolved.target.origin)], ); - if (Option.isNone(token)) return yield* new UiUnauthorized(); + if (Option.isNone(token)) + return yield* Effect.andThen( + recordCredential({ kind: "none" }), + refuse("missing", new UiUnauthorized()), + ); + yield* recordCredential({ kind: "app_session" }); const sessions = yield* HostedAppSessions; const identity = yield* sessions.current(resolved.target, token.value); + yield* recordCredential({ + kind: "app_session", + user: identity.userId, + impersonatedBy: identity.impersonatedBy, + }); const app = resolved.app; const access = yield* requireAppUse(app, resolved.target.organization, identity.userId).pipe( Effect.mapError((error) => Schema.is(OrganizationForbidden)(error) ? new UiForbidden() : unavailable(), ), + Effect.tapErrorTag("UiForbidden", () => recordAuthFailure("resource_denied")), ); + yield* recordCaller(resolved.target.organization, identity.userId); return { ...resolved, access, app }; }); const authorize = Effect.flatMap(target, authorizeTarget); @@ -514,7 +527,10 @@ export const hostedAppUi = ( const request = yield* HttpServerRequest.HttpServerRequest; const host = addresses.fromHost(request.headers.host); if (Option.isSome(host) && anonymousNonNavigation(request, sessionCookie(host.value.origin))) - return yield* new UiForbidden(); + return yield* Effect.andThen( + recordCredential({ kind: "none" }), + refuse("missing", new UiForbidden()), + ); const resolved = yield* target; const navigation = request.method === "GET" && diff --git a/apps/hosted/server/src/implementation/auth-telemetry.ts b/apps/hosted/server/src/implementation/auth-telemetry.ts new file mode 100644 index 0000000000..2acc1c0e9e --- /dev/null +++ b/apps/hosted/server/src/implementation/auth-telemetry.ts @@ -0,0 +1,125 @@ +/** + * What a request's span records about its caller, wherever in the request the check that + * learns it runs. No credential value, hash or prefix is ever recorded: only its kind and an + * identifier that is not a secret (an API key's ID, an OAuth client's ID, a session's row ID). + */ +import { annotateRequestSpan, currentRequestSpan } from "@executor-js/telemetry/http"; +import { Effect, Option, Schema } from "effect"; +import { HttpEffect, type HttpServerResponse } from "effect/http"; +import { + callerSpanAttributes, + type AuthCredentialKind, + type AuthFailureReason, + type Principal, +} from "../contracts/auth.ts"; +import { OrganizationForbidden } from "../contracts/organization.ts"; + +/** + * Name the caller's own organization and the caller, by their opaque IDs, on the current span and + * on the request's span. Call it only once the organization is known to be the caller's. + */ +export const recordCaller = (organization: string, user: string) => { + const attributes = callerSpanAttributes(organization, user); + return Effect.andThen(Effect.annotateCurrentSpan(attributes), annotateRequestSpan(attributes)); +}; + +/** + * Name the credential the request presented on its request span: its kind as soon as it is known, + * its non-secret ID once it is found, and its user once it is verified. A later refusal, such as + * for an organization the user does not belong to, keeps them. + */ +export const recordCredential = (credential: { + readonly kind: AuthCredentialKind; + readonly id?: string | undefined; + readonly user?: string | undefined; + /** The admin whose impersonation created the session, from the stored session row. */ + readonly impersonatedBy?: string | undefined; +}) => + annotateRequestSpan({ + "auth.credential.kind": credential.kind, + ...(credential.id === undefined ? {} : { "auth.credential.id": credential.id }), + ...(credential.user === undefined ? {} : { "executor.user.id": credential.user }), + ...(credential.impersonatedBy === undefined + ? {} + : { "auth.impersonated_by": credential.impersonatedBy }), + }); + +/** + * Name a verified session on the request span. In a session an admin's impersonation created, + * `executor.user.id` is the impersonated user and `auth.impersonated_by` the admin. + */ +export const recordSessionCredential = (principal: Principal) => + recordCredential({ + kind: "session", + id: principal.sessionId, + user: principal.userId, + impersonatedBy: principal.impersonatedBy, + }); + +/** + * Name why the request was refused on its request span (`auth.failure.reason`). A request that + * checks its credential again, such as each tool call inside MCP `execute`, keeps the last refusal. + */ +export const recordAuthFailure = (reason: AuthFailureReason) => + annotateRequestSpan({ "auth.failure.reason": reason }); + +/** + * Record why no session was found: `missing` with no session cookie, else `unknown` (a signed-out, + * expired or forged session). The cookie is a hint here only; Better Auth decided the session. + */ +export const recordNoSession = (headers: Headers) => + presentsSessionCookie(headers) + ? Effect.andThen(recordCredential({ kind: "session" }), recordAuthFailure("unknown")) + : Effect.andThen(recordCredential({ kind: "none" }), recordAuthFailure("missing")); + +/** Whether the request carries a session cookie, valid or not, under any cookie prefix. */ +const presentsSessionCookie = (headers: Headers) => + /(?:^|;)\s*[^=;\s]*\.session_token=/u.test(headers.get("cookie") ?? ""); + +/** + * Record `missing` when the request presented no credential at all: no session cookie and no + * Authorization header. A request that presented one records nothing here. + */ +export const recordNoCredential = (headers: Headers) => + headers.has("authorization") || presentsSessionCookie(headers) + ? Effect.void + : Effect.andThen(recordCredential({ kind: "none" }), recordAuthFailure("missing")); + +/** Fail with `error`, recording `reason` first. */ +export const refuse = (reason: AuthFailureReason, error: E) => + Effect.andThen(recordAuthFailure(reason), Effect.fail(error)); + +/** Record `reason` when the check fails with `OrganizationForbidden`; the failure is unchanged. */ +export const refusedAs = + (reason: AuthFailureReason) => + (effect: Effect.Effect) => + Effect.tapError(effect, (error) => + Schema.is(OrganizationForbidden)(error) ? recordAuthFailure(reason) : Effect.void, + ); + +/** + * Give every 401 and 403 a reason. A refusal that no check named is `unclassified`, except a 403 + * after the caller's membership was confirmed (`executor.organization.id`), which only a check on + * the resource itself can produce: `resource_denied`. Apply it in the request's span. + */ +export const recordUnclassifiedRefusals = ( + handler: Effect.Effect, +) => + Effect.gen(function* () { + const span = yield* currentRequestSpan; + if (Option.isSome(span)) + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.sync(() => { + const refused = response.status === 401 || response.status === 403; + if (refused && !span.value.attributes.has("auth.failure.reason")) + span.value.attribute( + "auth.failure.reason", + response.status === 403 && span.value.attributes.has("executor.organization.id") + ? "resource_denied" + : "unclassified", + ); + return response; + }), + ); + return yield* handler; + }); diff --git a/apps/hosted/server/src/implementation/auth.ts b/apps/hosted/server/src/implementation/auth.ts index 3c423a43c0..af0172737e 100644 --- a/apps/hosted/server/src/implementation/auth.ts +++ b/apps/hosted/server/src/implementation/auth.ts @@ -1,5 +1,6 @@ import { apiKeys, apiKeyManagement } from "./api-keys.ts"; import { requestAuthentication } from "./batch-authentication.ts"; +import { recordNoSession, recordSessionCredential, refuse } from "./auth-telemetry.ts"; import { CurrentUsage, isReadMethod, @@ -127,7 +128,7 @@ export const authOptions = ( export const sessionPrincipal = ( session: { readonly user: { readonly id: string; readonly name: string }; - readonly session: { readonly id: string }; + readonly session: { readonly id: string; readonly impersonatedBy?: string | null | undefined }; } | null, ) => session === null @@ -136,6 +137,9 @@ export const sessionPrincipal = ( userId: session.user.id, sessionId: session.session.id, name: session.user.name, + ...(typeof session.session.impersonatedBy === "string" + ? { impersonatedBy: session.session.impersonatedBy } + : {}), }).pipe(Effect.mapError(() => new AuthenticationUnavailable())); /** Authenticate each request and reject foreign-origin cookie writes. */ @@ -147,17 +151,21 @@ export const requireUserLive = Layer.effect( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; // Browser-only endpoints never fall back from a presented grant to a cookie. - if (request.headers.authorization !== undefined) return yield* new Unauthorized(); + if (request.headers.authorization !== undefined) + return yield* refuse("bearer_not_allowed", new Unauthorized()); if ( request.method !== "GET" && request.method !== "HEAD" && request.headers.origin !== auth.origin ) { - return yield* Effect.fail(new Forbidden()); + return yield* refuse("origin_mismatch", new Forbidden()); } const identity = yield* requestAuthentication(auth); - const principal = yield* identity.current(new Headers(request.headers)); - if (principal === null) return yield* Effect.fail(new Unauthorized()); + const headers = new Headers(request.headers); + const principal = yield* identity.current(headers); + if (principal === null) + return yield* Effect.andThen(recordNoSession(headers), Effect.fail(new Unauthorized())); + yield* recordSessionCredential(principal); const operation = { area: group.identifier, operation: endpoint.identifier, diff --git a/apps/hosted/server/src/implementation/authorization.ts b/apps/hosted/server/src/implementation/authorization.ts index 9076af1d37..2542a02906 100644 --- a/apps/hosted/server/src/implementation/authorization.ts +++ b/apps/hosted/server/src/implementation/authorization.ts @@ -18,12 +18,14 @@ import { Effect } from "effect"; import { CurrentAuthorization } from "../contracts/authorization.ts"; import { HostedExecutor } from "../contracts/executor.ts"; import { OrganizationForbidden } from "../contracts/organization.ts"; +import { refuse } from "./auth-telemetry.ts"; /** Check the shared app policy before reading or evaluating an app; ownership is checked separately. */ export const authorizeApp = (app: AppId) => Effect.gen(function* () { const policy = yield* CurrentAuthorization; - if (!permitsApp(policy, app)) return yield* new OrganizationForbidden(); + if (!permitsApp(policy, app)) + return yield* refuse("insufficient_scope", new OrganizationForbidden()); return policy; }); /** @@ -68,7 +70,8 @@ export const authorizeTool = ( accounts: userAccountIds(input.accounts), }; if (!requiresToolMetadata(policy.tools, input.app)) { - if (!permitsTool(policy, request)) return yield* new OrganizationForbidden(); + if (!permitsTool(policy, request)) + return yield* refuse("insufficient_scope", new OrganizationForbidden()); return { tool: undefined, kind: input.kind } satisfies ToolAuthorization; } const executor = yield* Effect.flatten(HostedExecutor); @@ -78,9 +81,11 @@ export const authorizeTool = ( .get(input) .pipe(Effect.catchTag("ToolNotFound", () => Effect.fail(new OrganizationForbidden())))); const permission = runPermission(policy, { ...request, tool }); - if (permission === undefined) return yield* new OrganizationForbidden(); + if (permission === undefined) + return yield* refuse("insufficient_scope", new OrganizationForbidden()); if (permission === "any") return { tool, kind: input.kind } satisfies ToolAuthorization; - if (input.kind === "mutation") return yield* new OrganizationForbidden(); + if (input.kind === "mutation") + return yield* refuse("insufficient_scope", new OrganizationForbidden()); return { tool, kind: "query" } satisfies ToolAuthorization; }); /** diff --git a/apps/hosted/server/src/implementation/bearer-access.ts b/apps/hosted/server/src/implementation/bearer-access.ts index 9bcfe5d84a..8978c68d62 100644 --- a/apps/hosted/server/src/implementation/bearer-access.ts +++ b/apps/hosted/server/src/implementation/bearer-access.ts @@ -36,11 +36,8 @@ import { type OrganizationAccess, } from "../contracts/organization.ts"; import { McpAccess, McpForbidden, McpForbiddenReason, McpUnauthorized } from "../contracts/mcp.ts"; -import { - AuthenticationUnavailable, - Unauthorized, - callerSpanAttributes, -} from "../contracts/auth.ts"; +import { AuthFailureReason, AuthenticationUnavailable, Unauthorized } from "../contracts/auth.ts"; +import { recordAuthFailure, recordCaller, recordCredential } from "./auth-telemetry.ts"; import { OrganizationForbidden } from "../contracts/organization.ts"; import { isApiKey } from "./api-keys.ts"; import { patGrantId, type HostedOAuthOrigins } from "./mcp-oauth.ts"; @@ -51,11 +48,15 @@ import { patGrantId, type HostedOAuthOrigins } from "./mcp-oauth.ts"; */ class BearerRejected extends Schema.TaggedError()("BearerRejected", { reason: Schema.Union([Schema.Literals(["unauthorized", "unavailable"]), McpForbiddenReason]), + /** Why, as the request's span records it. An outage is not a refusal and has none. */ + failure: Schema.optionalKey(AuthFailureReason), }) {} -const unauthorized = new BearerRejected({ reason: "unauthorized" }); +const unauthorized = (failure: AuthFailureReason) => + new BearerRejected({ reason: "unauthorized", failure }); const unavailable = new BearerRejected({ reason: "unavailable" }); -const forbidden = (reason: McpForbiddenReason) => new BearerRejected({ reason }); +const forbidden = (reason: McpForbiddenReason, failure: AuthFailureReason) => + new BearerRejected({ reason, failure }); /** What the request names: the presented credential and any organization it selects. */ export interface BearerRequest { @@ -74,6 +75,16 @@ export interface ApiBearerAccess { const bearer = (headers: Headers) => headers.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1]; +/** A request without a bearer: none at all, or an Authorization header of another form. */ +const noBearer = (headers: Headers) => + headers.has("authorization") + ? Effect.fail(unauthorized("malformed")) + : Effect.andThen(recordCredential({ kind: "none" }), Effect.fail(unauthorized("missing"))); + +/** Record why a bearer was refused on the request's span, before translating it. */ +const recordRefusal = (error: BearerRejected) => + error.failure === undefined ? Effect.void : recordAuthFailure(error.failure); + /** * Better Auth stores opaque access tokens and API keys as the unpadded base64url SHA-256 of * the credential (oauth-provider's default `storeTokens: "hashed"`, api-key's default hasher). @@ -135,33 +146,34 @@ const requestedOrganization = ( ) => Effect.gen(function* () { if (header !== null && !Schema.is(OrganizationReference)(header)) - return yield* forbidden("membership"); + return yield* forbidden("membership", "malformed"); const resolve = (matches: string) => Schema.decodeUnknownEffect(Schema.Array(OrganizationId))(jsonText(matches)).pipe( Effect.mapError(() => unavailable), Effect.flatMap((ids) => ids.length === 1 && ids[0] !== undefined ? Effect.succeed(ids[0]) - : Effect.fail(forbidden("membership")), + : Effect.fail(forbidden("membership", "organization_not_found")), ), ); const byUrl = request.organization === undefined ? undefined : yield* resolve(fromUrl); const byHeader = header === null ? undefined : yield* resolve(fromHeader); if (byUrl !== undefined && byHeader !== undefined && byUrl !== byHeader) - return yield* forbidden("organization_mismatch"); + return yield* forbidden("organization_mismatch", "organization_mismatch"); return byUrl ?? byHeader; }); -/** - * Name the caller's own organization and the caller on the caller's span (`auth.authenticate`), by - * their opaque IDs, once the check knows the organization is theirs, so a refusal after this point - * still names both: an OAuth grant's organization and user, or a pinned key's organization and its - * user, from when the statement answers, and the organization an unpinned key names only after its - * user's membership there holds. Neither is recorded before then, so refused requests for another - * organization never count against it, nor count their user among its users. +/* + * `recordCaller` names the caller's own organization and the caller on the caller's span + * (`auth.authenticate`) and the request's span, by their opaque IDs, once the caller's current + * membership there holds, so a refusal after this point still names both: an OAuth grant's + * organization once its user's membership there holds, a pinned key's while its user is still a + * member there, and the organization an unpinned key names once its user's membership there + * holds. No organization is recorded before then, so refused requests for another organization, + * or for one the caller has left, never count against it. The request's span names the verified + * credential's user earlier (`recordCredential`), so such a refusal is still attributed to its + * caller. */ -const recordCaller = (organization: string, user: string) => - Effect.annotateCurrentSpan(callerSpanAttributes(organization, user)); /** Organizations matching a reference by ID or slug; two rows prove the reference ambiguous. */ const matches = (sql: SqlClient.SqlClient, reference: string | null) => @@ -288,24 +300,30 @@ const oauthAccess = ( ) => Effect.gen(function* () { const header = request.headers.get("x-executor-organization"); + yield* recordCredential({ kind: "oauth_access_token" }); const rows = yield* loadOAuth( sql, yield* storedHash(token), request.organization ?? null, header, ); - if (rows[0] === undefined) return yield* unauthorized; + if (rows[0] === undefined) return yield* unauthorized("unknown"); const row = yield* Schema.decodeUnknownEffect(OAuthRow)(rows[0]).pipe( Effect.mapError(() => unavailable), ); // Better Auth's opaque token validation: live token, enabled client, live session. It reads // the clock after its reads, so a token that expires during the round trip is refused. const now = yield* Clock.currentTimeMillis; - if (row.expiresAt === null || row.expiresAt < now || row.revoked) return yield* unauthorized; - if (!row.clientActive || row.bound) return yield* unauthorized; + if (row.revoked) return yield* unauthorized("revoked"); + if (row.expiresAt === null || row.expiresAt < now) return yield* unauthorized("expired"); + if (!row.clientActive) return yield* unauthorized("revoked"); + // A sender-constrained token is not a bearer credential for any resource here. + if (row.bound) return yield* unauthorized("wrong_resource"); if (row.session !== null && (row.sessionExpiresAt === null || row.sessionExpiresAt < now)) - return yield* unauthorized; - if (row.subject === null || row.tokenClient === null) return yield* unauthorized; + return yield* unauthorized("expired"); + if (row.subject === null || row.tokenClient === null) return yield* unauthorized("revoked"); + // The token is live; its client ID is public, and its subject is the caller. + yield* recordCredential({ kind: "oauth_access_token", id: row.tokenClient, user: row.subject }); // Every audience must still name a stored resource; `openid` adds the userinfo audience. const scopes = strings(row.scopes) ?? []; const resources = strings(row.resources) ?? []; @@ -319,7 +337,7 @@ const oauthAccess = ( resources.length === 0 || resources.some((resource) => resource !== userInfo && !stored.has(resource)) ) - return yield* unauthorized; + return yield* unauthorized("wrong_resource"); const audiences = scopes.includes("openid") && !resources.includes(userInfo) ? [...resources, userInfo] @@ -336,59 +354,55 @@ const oauthAccess = ( ) .at(-1); if (claimedGrant !== undefined && Reflect.get(claimedGrant, "grant_id") !== row.grantReference) - return yield* unauthorized; + return yield* unauthorized("wrong_resource"); // The grant this token was issued for, held by the same user and client. const grantId = yield* Schema.decodeUnknownEffect(GrantId)(row.grantReference).pipe( - Effect.mapError(() => unauthorized), + Effect.mapError(() => unauthorized("wrong_resource")), ); - if (row.grantId === null || row.grantRevoked !== false) return yield* unauthorized; + if (row.grantId === null || row.grantRevoked !== false) return yield* unauthorized("revoked"); if ( row.grantUser !== row.subject || row.grantClient !== row.tokenClient || row.grantResource === null || row.grantResource.length === 0 ) - return yield* unauthorized; + return yield* unauthorized("wrong_resource"); // The consent's one resource, at any of this deployment's origins, is the token's audience. const consented = row.consentResources === null ? [] : (strings(row.consentResources) ?? []); const target: GrantTarget | undefined = grantTarget(resourceOrigins, consented); - if (target === undefined) return yield* unauthorized; - if ( - audiences.length !== 1 || - audiences[0] !== consented[0] || - target.kind !== kind || - !scopes.includes(kind === "api" ? "executor" : "mcp") - ) - return yield* unauthorized; + if (target === undefined) return yield* unauthorized("wrong_resource"); + if (audiences.length !== 1 || audiences[0] !== consented[0] || target.kind !== kind) + return yield* unauthorized("wrong_resource"); + if (!scopes.includes(kind === "api" ? "executor" : "mcp")) + return yield* unauthorized("insufficient_scope"); // Live membership in the grant's organization. const organization = yield* Schema.decodeUnknownEffect(OrganizationId)(row.grantResource).pipe( - Effect.mapError(() => forbidden("membership")), + Effect.mapError(() => forbidden("membership", "not_member")), ); - // The grant's organization and user are the caller's own, whatever organization the request - // names; the grant's user is the token's subject (checked above). - yield* recordCaller(organization, row.subject); const role = yield* Schema.decodeUnknownEffect(OrganizationRole)(row.role).pipe( - Effect.mapError(() => forbidden("membership")), + Effect.mapError(() => forbidden("membership", "not_member")), ); + // The grant's organization and user are the caller's own while the membership holds, whatever + // organization the request names; the grant's user is the token's subject (checked above). + yield* recordCaller(organization, row.subject); // A connection grant has no authority of its own; it reads the connection on every use. const connection = row.grantConnection ?? undefined; if (connection !== (target.kind === "mcp" ? target.connection : undefined)) - return yield* unauthorized; + return yield* unauthorized("wrong_resource"); const policy = connection === undefined ? yield* Schema.decodeUnknownEffect(Schema.fromJsonString(GrantPolicy))( row.grantPolicy, - ).pipe(Effect.mapError(() => unauthorized)) - : row.connectionId === null || - row.connectionRevoked !== false || - row.connectionUser !== row.grantUser || - row.connectionResource !== row.grantResource - ? yield* unauthorized - : connectionGrantPolicy( - yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ConnectionPolicy))( - row.connectionPolicy, - ).pipe(Effect.mapError(() => unavailable)), - ); + ).pipe(Effect.mapError(() => unauthorized("unknown"))) + : row.connectionId === null || row.connectionRevoked !== false + ? yield* unauthorized("revoked") + : row.connectionUser !== row.grantUser || row.connectionResource !== row.grantResource + ? yield* unauthorized("wrong_resource") + : connectionGrantPolicy( + yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ConnectionPolicy))( + row.connectionPolicy, + ).pipe(Effect.mapError(() => unavailable)), + ); const requested = yield* requestedOrganization( request, header, @@ -396,7 +410,7 @@ const oauthAccess = ( row.headerOrganizations, ); if (requested !== undefined && requested !== organization) - return yield* forbidden("organization_mismatch"); + return yield* forbidden("organization_mismatch", "organization_mismatch"); return { userId: row.subject, clientId: row.tokenClient, @@ -495,9 +509,20 @@ const loadPat = ( const KeyMetadata = Schema.NullOr(ApiKeyMetadata); +/** Whether the statement's memberships give the key's user a role in `organization`. */ +const holdsRole = (memberships: string, organization: string) => + Option.match(Schema.decodeUnknownOption(Memberships)(jsonText(memberships)), { + onNone: () => false, + onSome: (items) => + items.some( + (item) => item.organization === organization && Schema.is(OrganizationRole)(item.role), + ), + }); + const patAccess = (sql: SqlClient.SqlClient, token: string, request: BearerRequest) => Effect.gen(function* () { const header = request.headers.get("x-executor-organization"); + yield* recordCredential({ kind: "api_key" }); const hash = yield* storedHash(token); const load = Effect.gen(function* () { const rows = yield* loadPat( @@ -507,7 +532,7 @@ const patAccess = (sql: SqlClient.SqlClient, token: string, request: BearerReque request.organization ?? null, header, ); - if (rows[0] === undefined) return yield* unauthorized; + if (rows[0] === undefined) return yield* unauthorized("unknown"); return yield* Schema.decodeUnknownEffect(PatRow)(rows[0]).pipe( Effect.mapError(() => unavailable), ); @@ -520,19 +545,24 @@ const patAccess = (sql: SqlClient.SqlClient, token: string, request: BearerReque if (row.contended) return yield* unavailable; // Disabled, expired and deleted-user keys are refused. const now = yield* Clock.currentTimeMillis; - if (!row.usable || (row.expiresAt !== null && now > row.expiresAt) || !row.userExists) - return yield* unauthorized; + // The key's ID is not a secret; a refused key still names which one was presented. + yield* recordCredential({ kind: "api_key", id: row.id }); + if (row.expiresAt !== null && now > row.expiresAt) return yield* unauthorized("expired"); + if (!row.usable || !row.userExists) return yield* unauthorized("revoked"); const id = yield* Schema.decodeUnknownEffect(ApiKeyId)(row.id).pipe( Effect.mapError(() => unavailable), ); + yield* recordCredential({ kind: "api_key", id, user: row.userId }); // Unreadable metadata fails closed: it cannot prove the key is an unpinned PAT. const metadata = yield* Schema.decodeUnknownEffect(KeyMetadata)(jsonText(row.metadata)).pipe( - Effect.mapError(() => unauthorized), + Effect.mapError(() => unauthorized("unknown")), ); const pinned = metadata?.organization; - // A pinned key's organization is its own, so a refusal from here on names that one and the - // key's user, never another organization the request names. - if (pinned !== undefined) yield* recordCaller(pinned, row.userId); + // A pinned key's organization is its own while its user is still a member there, so a refusal + // from here on names that one and the key's user, never another organization the request + // names. The refusals keep their order; this only reads the statement's memberships. + if (pinned !== undefined && holdsRole(row.memberships, pinned)) + yield* recordCaller(pinned, row.userId); // The named organization, else the one the key is pinned to. const organization = (yield* requestedOrganization( @@ -541,16 +571,17 @@ const patAccess = (sql: SqlClient.SqlClient, token: string, request: BearerReque row.urlOrganizations, row.headerOrganizations, )) ?? pinned; - if (organization === undefined) return yield* forbidden("organization_required"); + if (organization === undefined) + return yield* forbidden("organization_required", "organization_required"); // A pinned key never authorizes another organization. if (pinned !== undefined && pinned !== organization) - return yield* forbidden("organization_mismatch"); + return yield* forbidden("organization_mismatch", "organization_mismatch"); const memberships = yield* Schema.decodeUnknownEffect(Memberships)( jsonText(row.memberships), ).pipe(Effect.mapError(() => unavailable)); const membership = memberships.find((item) => item.organization === organization); const role = yield* Schema.decodeUnknownEffect(OrganizationRole)(membership?.role).pipe( - Effect.mapError(() => forbidden("membership")), + Effect.mapError(() => forbidden("membership", "not_member")), ); // An unpinned key can name any organization; it is the caller's own once membership holds. if (pinned === undefined) yield* recordCaller(organization, row.userId); @@ -570,7 +601,7 @@ export const mcpBearerAccess = ( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const token = bearer(request.headers); - if (token === undefined) return yield* unauthorized; + if (token === undefined) return yield* noBearer(request.headers); if (!isApiKey(token)) { const grant = yield* oauthAccess(sql, origins, "mcp", token, request); return McpAccess.make({ @@ -593,6 +624,7 @@ export const mcpBearerAccess = ( access: key.access, }); }).pipe( + Effect.tapError(recordRefusal), Effect.mapError(({ reason }) => reason === "unauthorized" ? new McpUnauthorized() @@ -607,7 +639,7 @@ export const apiBearerAccess = (origins: HostedOAuthOrigins, request: BearerRequ Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const token = bearer(request.headers); - if (token === undefined) return yield* unauthorized; + if (token === undefined) return yield* noBearer(request.headers); const access: Omit & { readonly organizationSlug: string | null; } = isApiKey(token) @@ -630,9 +662,10 @@ export const apiBearerAccess = (origins: HostedOAuthOrigins, request: BearerRequ ); const organizationSlug = access.organizationSlug; if (organizationSlug === null || organizationSlug.length === 0) - return yield* forbidden("membership"); + return yield* forbidden("membership", "not_member"); return { ...access, organizationSlug } satisfies ApiBearerAccess; }).pipe( + Effect.tapError(recordRefusal), Effect.mapError(({ reason }) => reason === "unauthorized" ? new Unauthorized() diff --git a/apps/hosted/server/src/implementation/mcp-connections.ts b/apps/hosted/server/src/implementation/mcp-connections.ts index bd020a73c5..b3ce0e707a 100644 --- a/apps/hosted/server/src/implementation/mcp-connections.ts +++ b/apps/hosted/server/src/implementation/mcp-connections.ts @@ -18,6 +18,7 @@ import { ScheduleWakeup } from "../contracts/schedules.ts"; import { checkAccounts, currentOwner, usableApp } from "./access.ts"; import { addLink, linkedAccounts, lockedChange } from "./app-accounts.ts"; import { policyDatabase, requireAccountAccessAs } from "./resource-policy.ts"; +import { audited } from "./security-audit.ts"; const owner = Effect.gen(function* () { const organization = yield* CurrentOrganization; @@ -162,13 +163,23 @@ export const hostedMcpConnectionHandlers = HttpApiBuilder.group( }), ) .handle("revoke", ({ params }) => - Effect.flatMap(owner, (current) => auth.connections.revoke(current, params.connection)), + Effect.flatMap(owner, (current) => + auth.connections.revoke(current, params.connection), + ).pipe( + audited("mcp_connection.revoke", { kind: "mcp_connection", id: (revoked) => revoked }), + Effect.asVoid, + ), ) .handle("agents", () => Effect.flatMap(owner, (current) => auth.connections.agents(current)), ) .handle("revokeAgent", ({ params }) => - Effect.flatMap(owner, (current) => auth.connections.revokeAgent(current, params.agent)), + Effect.flatMap(owner, (current) => + auth.connections.revokeAgent(current, params.agent), + ).pipe( + audited("mcp_agent.revoke", { kind: "mcp_agent", id: (revoked) => revoked }), + Effect.asVoid, + ), ); }), ); diff --git a/apps/hosted/server/src/implementation/mcp-http.ts b/apps/hosted/server/src/implementation/mcp-http.ts index 85f39c35ab..c0c120fde1 100644 --- a/apps/hosted/server/src/implementation/mcp-http.ts +++ b/apps/hosted/server/src/implementation/mcp-http.ts @@ -4,6 +4,7 @@ import { requiresToolMetadata } from "@executor-js/authorization"; import { grantAuthorization } from "@executor-js/mcp-auth"; import { GroupDatabase } from "../contracts/groups.ts"; import { CurrentUserId, callerSpanAttributes } from "../contracts/auth.ts"; +import { recordAuthFailure } from "./auth-telemetry.ts"; import { restrictMcpBackend, deliveryRefusal, @@ -467,7 +468,10 @@ export const authenticatedMcp = ( const request = yield* HttpServerRequest.HttpServerRequest; // Programmatic clients have no Origin. Untrusted browser pages cannot call MCP. if (request.headers.origin !== undefined && request.headers.origin !== auth.origin) - return HttpServerResponse.empty({ status: 403 }); + return yield* Effect.as( + recordAuthFailure("origin_mismatch"), + HttpServerResponse.empty({ status: 403 }), + ); const url = new URL(request.url, auth.origin); const address = requestedMcpAddress(url); if (address === undefined) return invalidAddress; diff --git a/apps/hosted/server/src/implementation/mcp-oauth.ts b/apps/hosted/server/src/implementation/mcp-oauth.ts index d61760995c..c3b300c22e 100644 --- a/apps/hosted/server/src/implementation/mcp-oauth.ts +++ b/apps/hosted/server/src/implementation/mcp-oauth.ts @@ -1,6 +1,6 @@ import { ApiKeyId } from "../contracts/api-keys.ts"; import { browserPersonalTokenAccess, apiKeyAccess, requirePinnedOrganization } from "./api-keys.ts"; -import { ApprovalMode, type ConnectionId, GrantId, mcpOAuthResources } from "@executor-js/mcp-auth"; +import { ApprovalMode, ConnectionId, GrantId, mcpOAuthResources } from "@executor-js/mcp-auth"; /** Hosted membership composes with the shared OAuth grant lifecycle. */ import type { BetterAuthPlugin, GenericEndpointContext } from "@better-auth/core"; @@ -306,12 +306,15 @@ export const mcpConnectionStore = ( : new AuthenticationUnavailable(), ), ), + // A revocation's steps run in Better Auth, which a cancelled request does not stop, so the + // call is awaited for its stored outcome. revoke: (owner, id) => request( (api) => api.revokeMcpConnection({ body: { ...owner, id } }), - Schema.Struct({ revoked: Schema.Literal(true) }), + Schema.Struct({ revoked: Schema.Literal(true), id: ConnectionId }), ).pipe( - Effect.asVoid, + Effect.map((revoked) => revoked.id), + Effect.uninterruptible, Effect.mapError((status) => status === 404 ? new ConnectionNotFound({ connection: id }) @@ -325,9 +328,10 @@ export const mcpConnectionStore = ( revokeAgent: (owner, id) => request( (api) => api.revokeMcpAgent({ body: { ...owner, id } }), - Schema.Struct({ revoked: Schema.Literal(true) }), + Schema.Struct({ revoked: Schema.Literal(true), id: GrantId }), ).pipe( - Effect.asVoid, + Effect.map((revoked) => revoked.id), + Effect.uninterruptible, Effect.mapError((status) => status === 404 ? new ConnectedAgentNotFound({ agent: id }) diff --git a/apps/hosted/server/src/implementation/oauth-clients.ts b/apps/hosted/server/src/implementation/oauth-clients.ts index 5559f2932f..bfddc5a924 100644 --- a/apps/hosted/server/src/implementation/oauth-clients.ts +++ b/apps/hosted/server/src/implementation/oauth-clients.ts @@ -30,6 +30,7 @@ import { } from "../contracts/resource-access.ts"; import { requireGroupSharing } from "./group-sharing.ts"; import { oauthClientAudience } from "./resource-lifecycle.ts"; +import { audited } from "./security-audit.ts"; import { currentResourceAuthority, oauthClientAccesses, @@ -138,6 +139,10 @@ export const registerOAuthClient = ({ return { client, access }; }).pipe( Effect.catchTags({ SqlError: () => new StorageError(), SchemaError: () => new StorageError() }), + audited("provider_oauth_client.create", { + kind: "provider_oauth_client", + id: ({ client }) => client.id, + }), ); /** Remove a client; accounts connected with it keep the copy their grants hold. */ @@ -145,12 +150,20 @@ export const removeOAuthClient = (client: OAuthClientId) => Effect.gen(function* () { const actor = yield* administrator; const executor = yield* Effect.flatten(HostedExecutor); - yield* requireOAuthClientAccessAs(actor, client, "manage"); - return yield* executor.oauthClients.remove({ + const access = yield* requireOAuthClientAccessAs(actor, client, "manage"); + const removed = yield* executor.oauthClients.remove({ owner: organizationOwner(actor.organization), - client, + client: access.client, }); - }); + return { removed, client: access.client }; + }).pipe( + // The stored client the caller was allowed to manage. + audited("provider_oauth_client.delete", { + kind: "provider_oauth_client", + id: ({ client }) => client, + }), + Effect.map(({ removed }) => removed), + ); /** Group order carries no meaning; the policy reads them sorted. */ const sameAudience = (a: typeof AppAudience.Type, b: typeof AppAudience.Type) => diff --git a/apps/hosted/server/src/implementation/organization.ts b/apps/hosted/server/src/implementation/organization.ts index 7fced3bd3c..1ad94cddb3 100644 --- a/apps/hosted/server/src/implementation/organization.ts +++ b/apps/hosted/server/src/implementation/organization.ts @@ -22,7 +22,14 @@ import { readOrganizationIconUpload } from "./organization-icons.ts"; import { OrganizationTombstones } from "../contracts/organization-removal.ts"; import { requireOrganizationAdmin } from "./access.ts"; import { callerLinkedAccounts } from "./app-accounts.ts"; -import { CurrentPrincipal, CurrentUserId, callerSpanAttributes } from "../contracts/auth.ts"; +import { CurrentPrincipal, CurrentUserId } from "../contracts/auth.ts"; +import { + recordCaller, + recordNoSession, + recordSessionCredential, + refuse, + refusedAs, +} from "./auth-telemetry.ts"; import { APIError } from "better-auth/api"; import { ErrorReporter, Effect, Layer, Schema } from "effect"; import { HttpApiBuilder } from "effect/http-api"; @@ -102,7 +109,9 @@ export const lookupOrganizationSlug = (call: () => Promise) => const refuseRemoved = (organization: OrganizationId) => Effect.flatMap(requestTombstones, (removed) => removed(organization)).pipe( Effect.mapError(() => new AuthenticationUnavailable()), - Effect.flatMap((gone) => (gone ? Effect.fail(new OrganizationForbidden()) : Effect.void)), + Effect.flatMap((gone) => + gone ? refuse("organization_not_found", new OrganizationForbidden()) : Effect.void, + ), ); /** Access resolves only the explicit route ID or slug. Shared session preferences never participate. */ @@ -120,25 +129,29 @@ export const withOrganizationRequest = ( const params = yield* HttpRouter.params; const reference = yield* Schema.decodeUnknownEffect(OrganizationReference)( params.organization, - ).pipe(Effect.mapError(() => new OrganizationForbidden())); + ).pipe( + Effect.mapError(() => new OrganizationForbidden()), + refusedAs("organization_not_found"), + ); if (headers.has("authorization")) { if (request.headers.origin !== undefined && request.headers.origin !== auth.origin) - return yield* new Forbidden(); + return yield* refuse("origin_mismatch", new Forbidden()); const grant = yield* api.authenticate(headers, reference); // The request's span records the organization's opaque ID, never the route's reference, // which can be its slug, and the caller's user ID: an OAuth grant's user or a key's. The // bearer check returns only an organization it authorized for the caller, so the refusals // below, a removed organization or a key's policy, keep both. - yield* Effect.annotateCurrentSpan( - callerSpanAttributes(grant.access.organization, grant.userId), - ); + yield* recordCaller(grant.access.organization, grant.userId); yield* refuseRemoved(grant.access.organization); - if (!permitsAction(grant.policy, action)) return yield* new OrganizationForbidden(); + if (!permitsAction(grant.policy, action)) + return yield* refuse("insufficient_scope", new OrganizationForbidden()); if (params.app !== undefined) { const app = yield* Schema.decodeUnknownEffect(AppId)(params.app).pipe( Effect.mapError(() => new OrganizationForbidden()), + refusedAs("insufficient_scope"), ); - if (!permitsApp(grant.policy, app)) return yield* new OrganizationForbidden(); + if (!permitsApp(grant.policy, app)) + return yield* refuse("insufficient_scope", new OrganizationForbidden()); } if (grant.key !== undefined) yield* Effect.annotateCurrentSpan("executor.api_key.id", grant.key.id); @@ -157,21 +170,27 @@ export const withOrganizationRequest = ( request.method !== "HEAD" && request.headers.origin !== auth.origin ) - return yield* new Forbidden(); + return yield* refuse("origin_mismatch", new Forbidden()); const identity = yield* requestAuthentication(auth); const principal = yield* identity.current(headers); - if (principal === null) return yield* new Unauthorized(); - const organization = yield* identity.organization(reference); + if (principal === null) + return yield* Effect.andThen(recordNoSession(headers), Effect.fail(new Unauthorized())); + yield* recordSessionCredential(principal); + const organization = yield* identity + .organization(reference) + .pipe(refusedAs("organization_not_found")); // Removal is reported before membership. The reads run one after the other: on Cloud they // share the event's one SQL connection, and overlapping them would open a second one, a TLS // login that costs far more than the few milliseconds the second read waits. yield* refuseRemoved(organization); - const membership = yield* identity.membership(principal, organization); + const membership = yield* identity + .membership(principal, organization) + .pipe(refusedAs("not_member")); // The route's reference resolves to any organization, so the request names it, and the session's // user, only once the membership shows it is the caller's own. A request refused before then, // for an organization the caller does not belong to or one being removed, names neither, and so // never counts against that organization's failures or among its users. - yield* Effect.annotateCurrentSpan(callerSpanAttributes(organization, principal.userId)); + yield* recordCaller(organization, principal.userId); const access = { organization, owner: organizationOwner(organization), diff --git a/apps/hosted/server/src/implementation/security-audit.ts b/apps/hosted/server/src/implementation/security-audit.ts new file mode 100644 index 0000000000..74937a495a --- /dev/null +++ b/apps/hosted/server/src/implementation/security-audit.ts @@ -0,0 +1,89 @@ +/** + * Emit `security.audit` log records. A record joins its request's trace, and copies what the + * request's span knows about the client and its credential, so it can be read on its own. + */ +import { currentRequestSpan } from "@executor-js/telemetry/http"; +import { Cause, Context, Effect, Exit, Option } from "effect"; +import { HttpServerRequest } from "effect/http"; +import { CurrentUserId } from "../contracts/auth.ts"; +import { CurrentOrganization } from "../contracts/organization.ts"; +import type { SecurityAuditAction, SecurityAuditEvent } from "../contracts/security-audit.ts"; + +/** + * Server span attributes every record repeats: the client's network and the credential, including + * the admin who impersonates the session's user. + */ +const copied = [ + "auth.failure.reason", + "geo.country.iso_code", + "cloudflare.asn", + "cloudflare.as_organization", + "auth.credential.kind", + "auth.credential.id", + "auth.impersonated_by", +] as const; + +/** Record one audited action. It never fails and reads nothing but the request's own context. */ +export const recordSecurityAudit = (event: SecurityAuditEvent) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const request = Context.getOption(context, HttpServerRequest.HttpServerRequest); + const organization = + event.organization ?? + Option.getOrUndefined( + Option.map( + Context.getOption(context, CurrentOrganization), + (current) => current.organization, + ), + ); + const user = event.user ?? Context.get(context, CurrentUserId); + const span = yield* currentRequestSpan; + const annotations: Record = { + "audit.action": event.action, + "audit.outcome": event.outcome, + }; + if (event.target !== undefined) { + annotations["audit.target.kind"] = event.target.kind; + if (event.target.id !== undefined) annotations["audit.target.id"] = event.target.id; + } + if (event.failure !== undefined) annotations["audit.failure"] = event.failure; + for (const [key, value] of Object.entries(event.detail ?? {})) + annotations[`audit.${key}`] = value; + if (user !== undefined) annotations["executor.user.id"] = user; + if (organization !== undefined) annotations["executor.organization.id"] = organization; + const address = Option.flatMap(request, (request) => request.remoteAddress); + if (Option.isSome(address)) annotations["client.address"] = address.value; + if (Option.isSome(span)) + for (const key of copied) { + const value = span.value.attributes.get(key); + if (typeof value === "string" || typeof value === "number") annotations[key] = value; + } + yield* Effect.logInfo("security.audit").pipe(Effect.annotateLogs(annotations)); + }); + +/** + * Record an action a product handler performs, with its outcome. A refusal carries the request's + * `auth.failure.reason` when a check recorded one. A request that ends before the action does is + * `interrupted`: a change it started may still be stored. Only a completed action names its target, + * by `target.id` of its result, a record the action found authorized or stored; what the request + * asked for can be any text. + */ +export const audited = + ( + action: SecurityAuditAction, + target: { readonly kind: string; readonly id: (value: A) => string | undefined }, + detail?: Readonly>, + ) => + (effect: Effect.Effect) => + Effect.onExit(effect, (exit) => + recordSecurityAudit({ + action, + outcome: Exit.isSuccess(exit) + ? "success" + : Cause.hasInterruptsOnly(exit.cause) + ? "interrupted" + : "failure", + target: { kind: target.kind, id: Exit.isSuccess(exit) ? target.id(exit.value) : undefined }, + detail, + }), + ); diff --git a/apps/hosted/server/src/index.ts b/apps/hosted/server/src/index.ts index 766abc12a1..1854a96a05 100644 --- a/apps/hosted/server/src/index.ts +++ b/apps/hosted/server/src/index.ts @@ -22,7 +22,26 @@ export { AuthenticationUnavailable, RequireUser, CurrentPrincipal, + AuthCredentialKind, + AuthFailureReason, } from "./contracts/auth.ts"; +export { + recordAuthFailure, + recordCaller, + recordCredential, + recordNoCredential, + recordNoSession, + recordUnclassifiedRefusals, + refuse, +} from "./implementation/auth-telemetry.ts"; +export { + SecurityAuditAction, + auditFailureCodes, + type SecurityAuditEvent, + type SecurityAuditFailure, + type SecurityAuditOutcome, +} from "./contracts/security-audit.ts"; +export { audited, recordSecurityAudit } from "./implementation/security-audit.ts"; export { authOptions, authSettings, sessionPrincipal } from "./implementation/auth.ts"; export * from "./contracts/organization.ts"; export { diff --git a/e2e/ci-selection.ts b/e2e/ci-selection.ts index 28348e8bb3..594bb55aa5 100644 --- a/e2e/ci-selection.ts +++ b/e2e/ci-selection.ts @@ -105,7 +105,7 @@ const jobs = { runner: 3, pattern: - "Cloud SSO SAML accepts|Cloud's operator OAuth client signs in|Safari reports only the page's own failures|Cloud cron triggers run their jobs|Cloud cron jobs record their outcome|Cloud support dialog lists every channel|Hosted feedback enforces its API contract|Executor's catalog calls an app's own cache methods|Executor app is installed with nothing left pending|Cloud MCP routes a request inside a session|Cloud MCP sessions continue after their session object is evicted|Request and workflow attempts at one team|remote skill catalog|a skill read without a revision|abandons a GitHub skills load|reads its catalog and the tool in one cache command|a cold call's wake is reported by that call alone|another organization's admin cannot read the link|account connections cannot cross organizations|Deploys wake webhook source setup|Cloud's schedule coordinator keeps nine slow scheduled runs|a webhook source reconciling for an activation that another supersedes|A skill catalog refreshed in the background after a dashboard batch read|Cloud MCP session objects run parallel tool calls|Cloud MCP session objects cancel a statement|Cloud MCP session objects discover the other apps|Cloud MCP session objects' database pool|Hosted MCP resume reports an approved call's own failure|Cloud refuses to remove an organization while one of its app workflows runs|Cloud v1 import (?!keeps at most the bound)|Hosted MCP operation and bearer-check spans name|Hosted management API request spans name only|Cloud's gateway names an authenticated MCP request's organization", + "Cloud SSO SAML accepts|Cloud's operator OAuth client signs in|Safari reports only the page's own failures|Cloud cron triggers run their jobs|Cloud cron jobs record their outcome|Cloud support dialog lists every channel|Hosted feedback enforces its API contract|Executor's catalog calls an app's own cache methods|Executor app is installed with nothing left pending|Cloud MCP routes a request inside a session|Cloud MCP sessions continue after their session object is evicted|Request and workflow attempts at one team|remote skill catalog|a skill read without a revision|abandons a GitHub skills load|reads its catalog and the tool in one cache command|a cold call's wake is reported by that call alone|another organization's admin cannot read the link|account connections cannot cross organizations|Deploys wake webhook source setup|Cloud's schedule coordinator keeps nine slow scheduled runs|a webhook source reconciling for an activation that another supersedes|A skill catalog refreshed in the background after a dashboard batch read|Cloud MCP session objects run parallel tool calls|Cloud MCP session objects cancel a statement|Cloud MCP session objects discover the other apps|Cloud MCP session objects' database pool|Hosted MCP resume reports an approved call's own failure|Cloud refuses to remove an organization while one of its app workflows runs|Cloud v1 import (?!keeps at most the bound)|Hosted MCP operation and bearer-check spans name|Hosted management API request spans name only|Hosted request spans record the client, the credential|Cloud's gateway names an authenticated MCP request's organization", }, // Cloud's hosts: role hosts, the edge and sign-in on `app.`. Run in `cloud-product`, their // load kept the team installation's workflow span from arriving within its 30-second wait, so diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 3f4f8e82a0..d5c755474c 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6156,6 +6156,17 @@ export const scenarios = plan({ local: na("Local has no organizations, so its MCP spans name none."), }, }, + securityObservability: { + fixtures: "actors", + file: "security-observability.spec.ts", + title: + "Hosted request spans record the client, the credential and why a request was refused, and security actions leave audit records, never a credential", + targets: { + "self-host": scheduled, + cloud: managedCloud, + local: na("Local has no hosted sign-in, API keys or organizations to refuse."), + }, + }, organizationRequestSpans: { fixtures: "actors", file: "organization-request-spans.spec.ts", diff --git a/e2e/tests/cloud-impersonation.spec.ts b/e2e/tests/cloud-impersonation.spec.ts index 2932e008e6..c760b40c88 100644 --- a/e2e/tests/cloud-impersonation.spec.ts +++ b/e2e/tests/cloud-impersonation.spec.ts @@ -1,9 +1,20 @@ +/** + * A platform admin impersonates a user through the shared dev widget and returns. Each start and + * stop leaves a `security.audit` record naming the admin as the actor and the user as the target, + * and everything done in the impersonation session names the admin as `auth.impersonated_by`, + * including requests in an app session signed in from it. `/api/viewer` never names the admin. + */ import { expect, layer } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect, Schedule, Schema } from "effect"; +import { randomBytes, randomUUID } from "node:crypto"; import { HostedLive, withHostedCase } from "../support/case.ts"; -import { Actors } from "../support/actors.ts"; +import { Actors, freshOwnerSession } from "../support/actors.ts"; import { Api, body } from "../support/api.ts"; +import { openPrivateApp, waitForAppUrl } from "../support/app-pages.ts"; +import { appsManifest } from "../support/apps-release.ts"; import { Browser } from "../support/browser.ts"; +import { App } from "../support/contracts.ts"; +import { Evidence, Telemetry } from "../support/evidence.ts"; import { Target } from "../support/platform.ts"; import { fixtureRequest } from "../sdk/fixtures.ts"; import { scenarios } from "../test-plan.ts"; @@ -12,6 +23,29 @@ const Identity = Schema.Struct({ user: Schema.Struct({ id: Schema.String, email: Schema.String }), session: Schema.Struct({ impersonatedBy: Schema.optional(Schema.NullOr(Schema.String)) }), }); +const LogRecords = Schema.fromJsonString( + Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + body: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), + }), +); +const auditKeys = [ + "audit.outcome", + "audit.failure", + "audit.target.kind", + "audit.target.id", + "executor.user.id", + "auth.impersonated_by", +] as const; +const traced = () => { + const traceId = randomBytes(16).toString("hex"); + return { traceId, traceparent: `00-${traceId}-${randomBytes(8).toString("hex")}-01` }; +}; + layer(HostedLive, { excludeTestServices: true })("Cloud impersonation", (it) => { it.effect(scenarios.cloudImpersonation.title, (context) => withHostedCase( @@ -20,7 +54,47 @@ layer(HostedLive, { excludeTestServices: true })("Cloud impersonation", (it) => const api = yield* Api, actors = yield* Actors, browser = yield* Browser, + evidence = yield* Evidence, + telemetry = yield* Telemetry, target = yield* Target; + /** The trace's one `security.audit` record for `action`, once it is delivered. */ + const audit = (traceId: string, action: string) => + telemetry.logs(traceId, "security.audit").pipe( + Effect.map(([trace]) => trace ?? ""), + Effect.tap((logs) => evidence.json(`audit-${action}-${traceId}.json`, logs)), + Effect.map((logs) => + Schema.decodeUnknownSync(LogRecords)(logs).data.filter( + (record) => + record.body === "security.audit" && record.attributes["audit.action"] === action, + ), + ), + Effect.filterOrFail( + (records) => records.length > 0, + () => new Error(`The ${action} record has not reached the collector`), + ), + Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 40 }), + Effect.map((records) => { + expect(records, `one ${action} record`).toHaveLength(1); + return Object.fromEntries(auditKeys.map((key) => [key, records[0]?.attributes[key]])); + }), + ); + /** The trace's request spans that `keep` selects, once they are delivered. */ + const serverSpans = ( + traceId: string, + keep: (tags: Readonly>) => boolean = () => true, + ) => + telemetry.query(traceId).pipe( + Effect.map((result) => + result.data + .map(({ span }) => span) + .filter((span) => span.operationName.startsWith("http.server ") && keep(span.tags)), + ), + Effect.filterOrFail( + (spans) => spans.length > 0, + () => new Error("The request span has not reached the collector"), + ), + Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 40 }), + ); const identity = (session: typeof actors.owner) => api .request(session, "GET", "/api/auth/get-session") @@ -118,6 +192,175 @@ layer(HostedLive, { excludeTestServices: true })("Cloud impersonation", (it) => page.request.get("/api/auth/admin/list-users").then((response) => response.status()), ), ).toBe(200); + + yield* evidence.step( + "Impersonation is audited as the admin's action on the user, and its session names the admin", + Effect.gen(function* () { + const refusal = traced(); + expect( + (yield* api.request( + actors.member, + "POST", + "/api/auth/admin/impersonate-user", + { userId: owner.user.id }, + { traceparent: refusal.traceparent }, + )).status, + ).toBe(403); + const refused = yield* audit(refusal.traceId, "impersonation.start"); + expect( + [refused["audit.outcome"], refused["audit.failure"], refused["audit.target.id"]], + "A refused impersonation names Better Auth's code and no target", + ).toEqual(["failure", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS", undefined]); + + const operator = yield* freshOwnerSession; + const start = traced(); + const started = yield* api.request( + operator, + "POST", + "/api/auth/admin/impersonate-user", + { userId: member.user.id }, + { traceparent: start.traceparent }, + ); + expect(started.status, JSON.stringify(started.body)).toBe(200); + expect( + yield* audit(start.traceId, "impersonation.start"), + "The start names the admin as the actor and the user as the target", + ).toEqual({ + "audit.outcome": "success", + "audit.failure": undefined, + "audit.target.kind": "user", + "audit.target.id": member.user.id, + "executor.user.id": owner.user.id, + "auth.impersonated_by": undefined, + }); + + const read = traced(); + expect( + (yield* api.request( + operator, + "GET", + `/api/organizations/${actors.organization.slug}/access`, + undefined, + { traceparent: read.traceparent }, + )).status, + ).toBe(200); + const served = yield* serverSpans(read.traceId); + expect( + served.map((span) => [ + span.tags["executor.user.id"], + span.tags["auth.impersonated_by"], + ]), + "A request in the impersonation session names the user and the admin", + ).toEqual([[member.user.id, owner.user.id]]); + + const create = traced(); + const created = yield* api.request( + operator, + "POST", + "/api/auth/api-key/create", + { name: "Created while impersonated" }, + { traceparent: create.traceparent }, + ); + expect(created.status, JSON.stringify(created.body)).toBe(200); + const key = yield* body(Schema.Struct({ id: Schema.String }), created); + expect( + yield* audit(create.traceId, "api_key.create"), + "So does an audited action in it", + ).toEqual({ + "audit.outcome": "success", + "audit.failure": undefined, + "audit.target.kind": "api_key", + "audit.target.id": key.id, + "executor.user.id": member.user.id, + "auth.impersonated_by": owner.user.id, + }); + + const viewer = yield* api.request(operator, "GET", "/api/viewer"); + expect(viewer.status).toBe(200); + expect( + viewer.body, + "/api/viewer returns the impersonated user and only its own fields, never the admin", + ).toEqual({ + userId: member.user.id, + sessionId: expect.any(String), + name: expect.any(String), + }); + + // A shared app opened from the impersonation session gets an app session whose + // requests name the impersonated user and the admin. + const prefix = `/api/organizations/${actors.organization.id}`; + const deployed = yield* api.request(actors.owner, "POST", `${prefix}/apps/deploy`, { + name: `Impersonated app ${randomUUID().slice(0, 8)}`, + files: [ + { + path: "index.ts", + content: 'import { defineApp } from "apps"; export default defineApp({}, {});', + }, + { path: "ui/index.html", content: "

Impersonated app

" }, + appsManifest, + ], + }); + expect(deployed.status, JSON.stringify(deployed.body)).toBe(200); + const app = yield* body(App, deployed); + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `${prefix}/apps/${app.id}`).pipe(Effect.orDie), + ); + const access = yield* body( + Schema.Struct({ revision: Schema.String }), + yield* api.request(actors.owner, "GET", `${prefix}/apps/${app.id}/access`), + ); + expect( + (yield* api.request(actors.owner, "PATCH", `${prefix}/apps/${app.id}/access`, { + revision: access.revision, + audience: { kind: "everyone" }, + })).status, + ).toBe(200); + const url = yield* waitForAppUrl(actors.owner, `${prefix}/apps/${app.id}/ui`); + yield* browser.login(operator); + yield* openPrivateApp(url); + const page = traced(); + expect( + yield* browser.use( + "Request the app page in the impersonated app session", + (current) => + current.request + .get(url, { headers: { traceparent: page.traceparent } }) + .then((response) => response.status()), + ), + ).toBe(200); + const appSpans = yield* serverSpans( + page.traceId, + (tags) => tags["auth.credential.kind"] === "app_session", + ); + expect( + appSpans.map((span) => [ + span.tags["executor.user.id"], + span.tags["auth.impersonated_by"], + ]), + "An app session request names the impersonated user and the admin", + ).toEqual([[member.user.id, owner.user.id]]); + + const stop = traced(); + const stopped = yield* api.request( + operator, + "POST", + "/api/auth/admin/stop-impersonating", + {}, + { traceparent: stop.traceparent }, + ); + expect(stopped.status, JSON.stringify(stopped.body)).toBe(200); + const ended = yield* audit(stop.traceId, "impersonation.stop"); + expect( + [ + ended["audit.outcome"], + ended["audit.target.kind"], + ended["audit.target.id"], + ended["executor.user.id"], + ], + "The stop names the admin as the actor and the user as the target", + ).toEqual(["success", "user", member.user.id, owner.user.id]); + }), + ); }), ), ); diff --git a/e2e/tests/mcp-organization-spans.spec.ts b/e2e/tests/mcp-organization-spans.spec.ts index 0f83c8e9cd..0044976921 100644 --- a/e2e/tests/mcp-organization-spans.spec.ts +++ b/e2e/tests/mcp-organization-spans.spec.ts @@ -11,9 +11,11 @@ * key's user), once it knows the organization is theirs. A grant's organization is known when its * statement answers, so a refusal after that, such as a grant used at an organization URL that * names none, keeps both. An unpinned key's is known only once its membership holds, so a key - * refused for an organization its user does not belong to names neither, on any span; a pinned key - * refused for another organization names its own. A refused request never names the organization - * it asked for, so it cannot count against that organization's failures or among its users. + * refused for an organization its user does not belong to names no organization, on any span; a + * pinned key refused for another organization names its own while its user is a member there; it + * is deleted with that membership, so once the user has left it names none. A refused request never names the + * organization it asked for, so it cannot count against that organization's failures. Its request + * span still names the key's verified user and why it was refused (`auth.failure.reason`). * * On Cloud the gateway's forward of a request it authenticated names both even when the session * object never answers; a request routed by its session id learns only the organization, from the @@ -530,11 +532,12 @@ try { return await ${tool("echo")}({ text: "after" }); } catch { return "refused ); yield* evidence.step( - "A key refused for an organization its user does not belong to never names that organization or its user", + "A key refused for an organization its user does not belong to never names that organization", Effect.gen(function* () { /** - * The bearer check of a request that names `reference`, which refuses it, and every - * span of that request that names an organization or a user. + * The bearer check of a request that names `reference`, which refuses it, every span + * of that request that names an organization, and the caller, credential and reason + * its request span names. */ const refusedCheck = (credential: Redacted.Redacted, reference: string) => Effect.gen(function* () { @@ -556,18 +559,26 @@ try { return await ${tool("echo")}({ text: "after" }); } catch { return "refused span.tags[userId], ]), naming: all - .filter( - (span) => - span.tags[organizationId] !== undefined || - span.tags[userId] !== undefined, - ) - .map((span) => span.operationName), + .filter((span) => span.tags[organizationId] !== undefined) + .map((span) => span.operationName) + .sort(), + request: all + .filter((span) => span.operationName === "http.server POST") + .map((span) => [ + span.tags[userId], + span.tags["auth.credential.kind"], + span.tags["auth.failure.reason"], + ]), }; }); const member = yield* identity(actors.member); const memberKey = yield* createKey("Organization spans, member", { actor: actors.member, }); + const memberPinned = yield* createKey("Organization spans, member pinned", { + actor: actors.member, + pinned: organization, + }); if (target.metadata.target === "cloud") { // Only Cloud lets a user hold several organizations, so only there can a key name // one its user never joined, or a key pinned to one name another. @@ -590,10 +601,11 @@ try { return await ${tool("echo")}({ text: "after" }); } catch { return "refused ); expect( yield* refusedCheck(memberKey.key, other.slug), - "An unpinned key names no organization its user never joined, nor its user", + "An unpinned key names no organization its user never joined; the request names its user", ).toEqual({ checks: [["error", "McpForbidden", undefined, undefined]], naming: [], + request: [[member.id, "api_key", "not_member"]], }); const pinned = yield* createKey("Organization spans, pinned", { pinned: organization, @@ -603,7 +615,8 @@ try { return await ${tool("echo")}({ text: "after" }); } catch { return "refused "A pinned key refused for another organization names only its own, with its user", ).toEqual({ checks: [["error", "McpForbidden", organization, owner]], - naming: ["auth.authenticate"], + naming: ["auth.authenticate", "http.server POST"], + request: [[owner, "api_key", "organization_mismatch"]], }); } // On both products the member leaves the organization. Its unpinned key outlives @@ -646,11 +659,39 @@ try { return await ${tool("echo")}({ text: "after" }); } catch { return "refused Effect.gen(function* () { expect( yield* refusedCheck(memberKey.key, actors.organization.slug), - "An unpinned key names no organization its user left, nor its user", + "An unpinned key names no organization its user left; the request names its user", ).toEqual({ checks: [["error", "McpForbidden", undefined, undefined]], naming: [], + request: [[member.id, "api_key", "not_member"]], + }); + // A pinned key ends with its membership, so the request is refused before + // any check names an organization. + const pinnedLeft = yield* post({ + credential: memberPinned.key, + headers: { "x-executor-organization": actors.organization.slug }, + message: initialize, }); + expect( + pinnedLeft.status, + "A key pinned to the organization its user left", + ).toBe(401); + const left = yield* spans( + pinnedLeft.traceId, + (all) => named(all, "auth.authenticate").length > 0, + ); + expect( + { + naming: left.filter((span) => span.tags[organizationId] !== undefined), + request: left + .filter((span) => span.operationName === "http.server POST") + .map((span) => [ + span.tags["auth.credential.kind"], + span.tags["auth.failure.reason"], + ]), + }, + "names no organization", + ).toEqual({ naming: [], request: [["api_key", "unknown"]] }); }), () => rejoin.pipe(Effect.orDie), ); diff --git a/e2e/tests/oauth-refresh-resilience.spec.ts b/e2e/tests/oauth-refresh-resilience.spec.ts index 5dce10c2e1..ab60865173 100644 --- a/e2e/tests/oauth-refresh-resilience.spec.ts +++ b/e2e/tests/oauth-refresh-resilience.spec.ts @@ -481,6 +481,12 @@ layer(HostedLive, { excludeTestServices: true })("OAuth refresh resilience", (it ).toMatchObject({ "oauth.provider.id": provider, "oauth.renewal.outcome": "service_unavailable", + // Who the credentials were decrypted for, whose account and why; never their values. + "oauth.resolve.purpose": "run", + "oauth.resolve.credential_type": "oauth2", + "executor.account.id": expect.any(String), + "executor.organization.id": actors.organization.id, + "executor.user.id": expect.any(String), }); expect( trace.data.find(({ span }) => span.operationName === "oauth.refresh")?.span.tags, diff --git a/e2e/tests/organization-request-spans.spec.ts b/e2e/tests/organization-request-spans.spec.ts index d976df8991..02cb15c7a3 100644 --- a/e2e/tests/organization-request-spans.spec.ts +++ b/e2e/tests/organization-request-spans.spec.ts @@ -2,13 +2,15 @@ * The hosted management API's request span names the organization it serves and the caller by * their opaque IDs, so one organization's failures can be told apart from another's and the users * they reach counted. A route names its organization by slug or ID, and any signed-in caller can - * send one, so the span names both only once the organization is the caller's own: a signed-in - * session's once its membership there holds, a bearer's once its check authorized it. A request - * refused after that, such as for an icon the organization does not have, keeps both. A request - * refused for an organization the caller never joined (only Cloud lets a user hold several) or one - * it left names neither, so it cannot count against that organization or among its users. A - * batched dashboard read's span (`dashboard.batch.read`) follows the same rule, and so does a - * bearer's check (`auth.authenticate`), which names only its own organization and user. + * send one, so the span names an organization only once it is the caller's own: a signed-in + * session's once its membership there holds, a bearer's once its check knows the grant's or key's + * organization. A request refused after that, such as for an icon the organization does not have, + * keeps both. A request refused for an organization the caller never joined (only Cloud lets a + * user hold several) or one it left never names that organization, so it cannot count against it. + * It still names its verified caller, the kind of credential (`auth.credential.kind`) and why it + * was refused (`auth.failure.reason`), so refusals can be attributed to who made them. A batched + * dashboard read's span (`dashboard.batch.read`) follows the same rules, and a bearer's check + * (`auth.authenticate`) names only its own organization and user. */ import { expect, layer } from "@effect/vitest"; import { Effect, Redacted, Schedule, Schema } from "effect"; @@ -75,6 +77,12 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( Effect.tap((spans) => evidence.json(`${label}.json`, spans)), ); const names = (span: Span) => [span.tags[organizationId], span.tags[userId]]; + /** A request span's organization, user, credential kind and refusal reason. */ + const requestNames = (span: Span) => [ + ...names(span), + span.tags["auth.credential.kind"], + span.tags["auth.failure.reason"], + ]; /** * `GET path` (whose route is `route`) as `actor`'s session or with `credential`, the status * and the organization and user its delivered request span records, and for a bearer the @@ -107,7 +115,7 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( status: response.status, spans: spans .filter(served) - .map((span) => [span.tags["http.response.status_code"], ...names(span)]), + .map((span) => [span.tags["http.response.status_code"], ...requestNames(span)]), checks: spans.filter(checked).map((span) => [span.status, ...names(span)]), }; }); @@ -148,7 +156,7 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( status: answer.status, spans: spans .filter(read) - .map((span) => [span.tags["http.response.status_code"], ...names(span)]), + .map((span) => [span.tags["http.response.status_code"], ...requestNames(span)]), }; }); const created = yield* api.request(actors.member, "POST", "/api/auth/api-key/create", { @@ -170,26 +178,37 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( expect( yield* access("member", actors.member, organization.slug), "The route names the organization by slug; the span names its ID and the session's user", - ).toEqual({ status: 200, spans: [["200", organization.id, member.id]], checks: [] }); + ).toEqual({ + status: 200, + spans: [["200", organization.id, member.id, "session", undefined]], + checks: [], + }); expect( yield* access("member-key", actors.member, organization.slug, key.key), "So does a request with the member's API key, and its check names the key's user", ).toEqual({ status: 200, - spans: [["200", organization.id, member.id]], + spans: [["200", organization.id, member.id, "api_key", undefined]], checks: [["ok", organization.id, member.id]], }); expect( yield* batched("member-batch", actors.member, organization.slug), "So does a batched dashboard read", - ).toEqual({ status: 200, spans: [["200", organization.id, member.id]] }); + ).toEqual({ + status: 200, + spans: [["200", organization.id, member.id, "session", undefined]], + }); // The organization has no icon under this key, so the request is refused after its // membership holds. const icon = `/api/organizations/${organization.slug}/icons/${randomBytes(32).toString("hex")}`; expect( yield* request("member-icon", actors.member, icon, iconRoute), "A request refused in the caller's own organization names both", - ).toEqual({ status: 404, spans: [["404", organization.id, member.id]], checks: [] }); + ).toEqual({ + status: 404, + spans: [["404", organization.id, member.id, "session", undefined]], + checks: [], + }); }), ); @@ -206,7 +225,7 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( yield* access("owner-grant", actors.owner, organization.slug, grantToken), ).toEqual({ status: 200, - spans: [["200", organization.id, owner.id]], + spans: [["200", organization.id, owner.id, "oauth_access_token", undefined]], checks: [["ok", organization.id, owner.id]], }); }), @@ -214,7 +233,7 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( if (target.metadata.target === "cloud") yield* evidence.step( - "A request for an organization the caller never joined names neither", + "A request for an organization the caller never joined names its caller, not it", Effect.gen(function* () { const response = yield* api.request( actors.owner, @@ -236,29 +255,48 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( expect( yield* access("owner-other", actors.owner, other.slug), "Its own member's request names it and them", - ).toEqual({ status: 200, spans: [["200", other.id, owner.id]], checks: [] }); + ).toEqual({ + status: 200, + spans: [["200", other.id, owner.id, "session", undefined]], + checks: [], + }); expect( yield* access("member-other", actors.member, other.slug), - "A signed-in session refused for it names neither", - ).toEqual({ status: 403, spans: [["403", undefined, undefined]], checks: [] }); + "A signed-in session refused for it names its user and why, not the organization", + ).toEqual({ + status: 403, + spans: [["403", undefined, member.id, "session", "not_member"]], + checks: [], + }); expect( yield* access("member-key-other", actors.member, other.slug, key.key), - "Nor does an API key refused for it, or its check", + "So does an API key refused for it; its check names neither", ).toEqual({ status: 403, - spans: [["403", undefined, undefined]], + spans: [["403", undefined, member.id, "api_key", "not_member"]], checks: [["error", undefined, undefined]], }); expect( yield* batched("member-batch-other", actors.member, other.slug), - "Nor does a batched read refused for it", - ).toEqual({ status: 403, spans: [["403", undefined, undefined]] }); + "So does a batched read refused for it", + ).toEqual({ + status: 403, + spans: [["403", undefined, member.id, "session", "not_member"]], + }); expect( yield* access("owner-grant-other", actors.owner, other.slug, grantToken), - "A grant used for another organization names neither on the request; its check names only the grant's own", + "A grant used for another organization names only the grant's own, on the request and its check", ).toEqual({ status: 403, - spans: [["403", undefined, undefined]], + spans: [ + [ + "403", + organization.id, + owner.id, + "oauth_access_token", + "organization_mismatch", + ], + ], checks: [["error", organization.id, owner.id]], }); }), @@ -267,7 +305,7 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( // On both products the member then leaves the organization. Its session and its key // outlive the membership. yield* evidence.step( - "A request for an organization the caller left names neither", + "A request for an organization the caller left names its caller, not it", Effect.gen(function* () { const removed = yield* api.request( actors.owner, @@ -278,20 +316,27 @@ layer(HostedLive, { excludeTestServices: true })("Organization request spans", ( expect(removed.status, JSON.stringify(removed.body)).toBe(200); expect( yield* access("member-left", actors.member, organization.slug), - "A signed-in session refused for it names neither", - ).toEqual({ status: 403, spans: [["403", undefined, undefined]], checks: [] }); + "A signed-in session refused for it names its user and why, not the organization", + ).toEqual({ + status: 403, + spans: [["403", undefined, member.id, "session", "not_member"]], + checks: [], + }); expect( yield* access("member-key-left", actors.member, organization.slug, key.key), - "Nor does an API key refused for it, or its check", + "So does an API key refused for it; its check names neither", ).toEqual({ status: 403, - spans: [["403", undefined, undefined]], + spans: [["403", undefined, member.id, "api_key", "not_member"]], checks: [["error", undefined, undefined]], }); expect( yield* batched("member-batch-left", actors.member, organization.slug), - "Nor does a batched read refused for it", - ).toEqual({ status: 403, spans: [["403", undefined, undefined]] }); + "So does a batched read refused for it", + ).toEqual({ + status: 403, + spans: [["403", undefined, member.id, "session", "not_member"]], + }); }), ); }).pipe(Effect.provide(McpOAuth.layer)), diff --git a/e2e/tests/security-observability.spec.ts b/e2e/tests/security-observability.spec.ts new file mode 100644 index 0000000000..21f53c6de8 --- /dev/null +++ b/e2e/tests/security-observability.spec.ts @@ -0,0 +1,633 @@ +/** + * Hosted requests can be attributed and refusals explained without any credential reaching + * telemetry. A request span names the kind of credential the request presented + * (`auth.credential.kind`), its non-secret ID (`auth.credential.id`, an API key's ID) and the + * verified user, and a 401 or 403 names why (`auth.failure.reason`): no credential, an + * Authorization header of another form, a token or key nobody issued, a key since deleted, or a + * write from another origin, which Better Auth refuses with its own code (`auth.failure.code`). A + * bare 401 from Better Auth is a missing credential only when the request presented none; a + * signed-in caller an endpoint refuses stays `unclassified`. On Cloud the span also names the + * client's address (`client.address`), and creating or deleting an API key, a refused one-time + * code, a refused role change, a consent, a refused agent revocation and a sign-out leave + * `security.audit` log records, each naming the session that acted when one was loaded. A body + * field an endpoint ignores changes nothing a record names. No span or record holds the key, the code or the email address, + * nor any ID, organization or OAuth error a caller sent: a record names only stored records and + * known codes. A sign-out is a confirmed revocation only where its session row was deleted. Each + * request's handler logs arrive, however many requests run before or beside it. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Redacted, Schedule, Schema } from "effect"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { scenarios } from "../test-plan.ts"; +import { Actors, freshOwnerSession } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import type { SpanQuery } from "../support/contracts.ts"; +import { Evidence, Telemetry } from "../support/evidence.ts"; +import { Target } from "../support/platform.ts"; + +type Span = (typeof SpanQuery.Type)["data"][number]["span"]; + +const Key = Schema.Struct({ id: Schema.String, key: Schema.RedactedFromValue(Schema.String) }); +const SessionIdentity = Schema.Struct({ + user: Schema.Struct({ id: Schema.String }), + session: Schema.Struct({ id: Schema.String }), +}); +const SocialStart = Schema.Struct({ url: Schema.RedactedFromValue(Schema.String) }); +const accessRoute = "/api/organizations/:organization/access"; +const LogRecords = Schema.fromJsonString( + Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + body: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), + }), +); +/** The named attributes of the trace's one `security.audit` record for `action`. */ +const auditRecord = (logs: string, action: string, keys: ReadonlyArray) => { + const records = Schema.decodeUnknownSync(LogRecords)(logs).data.filter( + (record) => record.body === "security.audit" && record.attributes["audit.action"] === action, + ); + expect(records, `one ${action} record`).toHaveLength(1); + return Object.fromEntries(keys.map((key) => [key, records[0]?.attributes[key]])); +}; + +layer(HostedLive, { excludeTestServices: true })("Security observability", (it) => { + it.effect( + scenarios.securityObservability.title, + (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + evidence = yield* Evidence, + telemetry = yield* Telemetry, + target = yield* Target; + const cloud = target.metadata.target === "cloud"; + const traced = () => { + const traceId = randomBytes(16).toString("hex"); + return { traceId, traceparent: `00-${traceId}-${randomBytes(8).toString("hex")}-01` }; + }; + /** A trace's delivered spans, once one of them is the request span of `route`. */ + const delivered = (label: string, traceId: string, route?: string) => + telemetry.query(traceId).pipe( + Effect.map((result) => result.data.map(({ span }) => span)), + Effect.flatMap((spans) => + spans.some( + (span) => + span.operationName.startsWith("http.server ") && + (route === undefined || span.tags["http.route"] === route), + ) + ? Effect.succeed(spans) + : Effect.fail(new Error("The request's spans have not reached the collector")), + ), + Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 40 }), + Effect.tap((spans) => evidence.json(`${label}.json`, spans)), + ); + /** `GET` the organization's access as `actor` with `headers`: its status and request span. */ + const access = (label: string, actor: Session, headers: Record = {}) => + Effect.gen(function* () { + const { traceId, traceparent } = traced(); + const response = yield* api.request( + actor, + "GET", + `/api/organizations/${actors.organization.slug}/access`, + undefined, + { traceparent, ...headers }, + ); + const spans = yield* delivered(label, traceId, accessRoute); + const served = spans.filter( + (span) => + span.operationName.startsWith("http.server ") && + span.tags["http.route"] === accessRoute, + ); + return { status: response.status, served, spans }; + }); + const named = (served: ReadonlyArray) => + served.map((span) => [ + span.tags["http.response.status_code"], + span.tags["auth.credential.kind"], + span.tags["auth.credential.id"], + span.tags["executor.user.id"], + span.tags["auth.failure.reason"], + ]); + const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); + /** The trace's `security.audit` records, once one names `action`. */ + const audit = (traceId: string, action: string) => + telemetry.logs(traceId, "security.audit").pipe( + Effect.map(([trace]) => trace ?? ""), + Effect.filterOrFail( + (logs) => logs.includes(action), + () => new Error(`The ${action} record has not reached the collector`), + ), + Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 40 }), + Effect.tap((logs) => evidence.json(`audit-${action}-${traceId}.json`, logs)), + ); + + const memberSession = yield* body( + SessionIdentity, + yield* api.request(actors.member, "GET", "/api/auth/get-session"), + ); + const member = memberSession.user; + const anonymous = yield* api.session(); + const created = traced(); + const createdResponse = yield* api.request( + actors.member, + "POST", + "/api/auth/api-key/create", + { name: "Security observability" }, + { traceparent: created.traceparent }, + ); + expect(createdResponse.status, JSON.stringify(createdResponse.body)).toBe(200); + const key = yield* body(Key, createdResponse); + const secret = Redacted.value(key.key); + /** No delivered span or record may hold the key, the session cookie or the email. */ + const assertPrivate = (value: unknown) => { + const json = JSON.stringify(value); + expect(json, "The API key never reaches telemetry").not.toContain(secret); + expect(json, "Nor does its unique part").not.toContain(secret.slice(-16)); + }; + + yield* evidence.step( + "A refused request names its credential's kind and why it was refused", + Effect.gen(function* () { + const missing = yield* access("missing", anonymous); + expect( + { status: missing.status, spans: named(missing.served) }, + "No credential at all", + ).toEqual({ + status: 401, + spans: [["401", "none", undefined, undefined, "missing"]], + }); + const malformed = yield* access("malformed", anonymous, { + authorization: "Basic c3ludGhldGljOnN5bnRoZXRpYw==", + }); + expect( + { status: malformed.status, spans: named(malformed.served) }, + "An Authorization header that is not a bearer", + ).toEqual({ + status: 401, + spans: [["401", undefined, undefined, undefined, "malformed"]], + }); + const unknownToken = yield* access( + "unknown-token", + anonymous, + bearer(randomBytes(24).toString("base64url")), + ); + expect( + { status: unknownToken.status, spans: named(unknownToken.served) }, + "An access token nobody issued", + ).toEqual({ + status: 401, + spans: [["401", "oauth_access_token", undefined, undefined, "unknown"]], + }); + const unknownKey = yield* access( + "unknown-key", + anonymous, + bearer(`exp_${randomBytes(24).toString("base64url")}`), + ); + expect( + { status: unknownKey.status, spans: named(unknownKey.served) }, + "An API key nobody issued", + ).toEqual({ + status: 401, + spans: [["401", "api_key", undefined, undefined, "unknown"]], + }); + const accepted = yield* access("accepted", anonymous, bearer(secret)); + expect( + { status: accepted.status, spans: named(accepted.served) }, + "A live key names its ID and its user, and no refusal", + ).toEqual({ + status: 200, + spans: [["200", "api_key", key.id, member.id, undefined]], + }); + for (const request of [missing, malformed, unknownToken, unknownKey, accepted]) + assertPrivate(request.spans); + if (cloud) { + // Cloud records why Better Auth's own endpoints refused, from the fixed code they + // answer with. + const foreign = traced(); + const refusedWrite = yield* api.request( + actors.member, + "POST", + "/api/auth/api-key/create", + { name: "Security observability, foreign origin" }, + { traceparent: foreign.traceparent, origin: "https://foreign.example.test" }, + ); + expect(refusedWrite.status, JSON.stringify(refusedWrite.body)).toBe(403); + const written = (yield* delivered("foreign-origin", foreign.traceId)).filter( + (span) => span.operationName.startsWith("http.server "), + ); + expect( + written.map((span) => [ + span.tags["auth.failure.reason"], + span.tags["auth.failure.code"], + ]), + "A write from another origin is refused as one", + ).toEqual([["origin_mismatch", "INVALID_ORIGIN"]]); + // Better Auth answers a bare 401 both without a session and to a signed-in caller + // it refuses: Cloud refuses OAuth client management to every session. + const refusedClient = (label: string, actor: Session) => + Effect.gen(function* () { + const { traceId, traceparent } = traced(); + const response = yield* api.request( + actor, + "POST", + "/api/auth/oauth2/create-client", + { redirect_uris: ["https://client.example.test/callback"] }, + { traceparent }, + ); + expect(response.status, JSON.stringify(response.body)).toBe(401); + return (yield* delivered(label, traceId)) + .filter((span) => span.operationName.startsWith("http.server ")) + .map((span) => [ + span.tags["auth.failure.reason"], + span.tags["auth.credential.kind"], + ]); + }); + expect( + yield* refusedClient("client-privileges", actors.member), + "A signed-in caller the endpoint refuses has no session missing", + ).toEqual([["unclassified", undefined]]); + expect( + (yield* body( + SessionIdentity, + yield* api.request(actors.member, "GET", "/api/auth/get-session"), + )).session.id, + "and the session it presented is still valid", + ).toBe(memberSession.session.id); + expect( + yield* refusedClient("client-anonymous", anonymous), + "A request with no credential at all is missing one", + ).toEqual([["missing", "none"]]); + } + if (cloud) + expect( + accepted.served[0]?.tags["client.address"], + "Cloud names the address the request came from", + ).toBeTypeOf("string"); + }), + ); + + yield* evidence.step( + "A deleted key is refused as unknown, and still never recorded", + Effect.gen(function* () { + const deleted = traced(); + const deletion = yield* api.request( + actors.member, + "POST", + "/api/auth/api-key/delete", + // Better Auth ignores a field the endpoint does not take, whatever its value. + { keyId: key.id, role: 42 }, + { traceparent: deleted.traceparent }, + ); + expect(deletion.status, JSON.stringify(deletion.body)).toBe(200); + const refused = yield* access("deleted-key", anonymous, bearer(secret)); + expect({ status: refused.status, spans: named(refused.served) }).toEqual({ + status: 401, + spans: [["401", "api_key", undefined, undefined, "unknown"]], + }); + assertPrivate(refused.spans); + if (!cloud) return; + for (const [traceId, action] of [ + [created.traceId, "api_key.create"], + [deleted.traceId, "api_key.delete"], + ] as const) { + const logs = yield* audit(traceId, action); + expect( + auditRecord(logs, action, [ + "audit.outcome", + "audit.target.id", + "executor.user.id", + "auth.credential.kind", + "auth.credential.id", + ]), + `${action} names the key it applied to and the session that acted`, + ).toEqual({ + "audit.outcome": "success", + "audit.target.id": key.id, + "executor.user.id": member.id, + "auth.credential.kind": "session", + "auth.credential.id": memberSession.session.id, + }); + assertPrivate(logs); + } + // An action a product handler performs is recorded too, a refused one included. + const revoke = traced(); + const revoked = yield* api.request( + actors.member, + "POST", + `/api/organizations/${actors.organization.slug}/mcp-agents/agent-${randomUUID()}/revoke`, + {}, + { traceparent: revoke.traceparent }, + ); + expect(revoked.status, JSON.stringify(revoked.body)).toBe(404); + const revokeLogs = yield* audit(revoke.traceId, "mcp_agent.revoke"); + expect(revokeLogs, "The refused revocation names its outcome").toContain("failure"); + expect(revokeLogs, "and its user").toContain(member.id); + expect(revokeLogs, "and the organization").toContain(actors.organization.id); + // A wrong code for an address nobody asked a code for is refused like any other. + const email = `security-observability-${randomUUID().slice(0, 8)}@example.test`; + const otp = traced(); + const refusedOtp = yield* api.request( + anonymous, + "POST", + "/api/auth/sign-in/email-otp", + { email, otp: "000000" }, + { traceparent: otp.traceparent }, + ); + expect(refusedOtp.status, JSON.stringify(refusedOtp.body)).toBe(400); + const logs = yield* audit(otp.traceId, "sign_in"); + expect(logs, "The refused sign-in names Better Auth's code").toContain("INVALID_OTP"); + expect(logs, "and its method").toContain("email_otp"); + expect(logs, "but never the address").not.toContain(email); + expect(logs, "or the code").not.toContain('"000000"'); + }), + ); + + if (!cloud) return; + + yield* evidence.step( + "Identifiers, organizations and error codes a caller sends never reach telemetry", + Effect.gen(function* () { + // Shaped like an ID or a fixed code, so only where a value comes from keeps it out. + const marker = `hostile_${randomBytes(12).toString("hex")}`; + const organizations = `/api/organizations/${actors.organization.slug}`; + /** + * Send a refused request in its own trace; its record, if `action` names one, and + * every span and log of the trace arrive without the marker. + */ + const refused = ( + label: string, + action: string | undefined, + send: (traceparent: string) => Effect.Effect, + ) => + Effect.gen(function* () { + const { traceId, traceparent } = traced(); + yield* send(traceparent); + const logs = action === undefined ? "" : yield* audit(traceId, action); + if (action !== undefined) + expect(logs, `${label}: the record names its outcome`).toMatch( + /"audit\.outcome":\s*"failure"/u, + ); + const spans = yield* delivered(label, traceId); + const [traceLogs, searched] = yield* telemetry.logs(traceId, marker); + for (const [where, value] of [ + ["records", logs], + ["spans", JSON.stringify(spans)], + ["trace logs", traceLogs ?? ""], + ["logs found by the marker", searched ?? ""], + ] as const) + expect(value, `${label}: no ${where} hold the marker`).not.toContain(marker); + return logs; + }); + const status = + (expected: (status: number) => boolean) => + (response: { readonly status: number; readonly body: unknown }) => + Effect.sync(() => + expect(expected(response.status), JSON.stringify(response.body)).toBe(true), + ); + const post = (path: string, data: unknown) => (traceparent: string) => + api + .request(actors.member, "POST", path, data, { traceparent }) + .pipe(Effect.tap(status((code) => code >= 400 && code < 500))); + yield* refused( + "key-delete", + "api_key.delete", + post("/api/auth/api-key/delete", { keyId: marker }), + ); + // `apiKeyManagement` refuses a pin to an organization the creator is not in before + // Better Auth's endpoint runs, so the request leaves no record at all. + yield* refused( + "key-create", + undefined, + post("/api/auth/api-key/create", { + name: "Security observability, hostile pin", + metadata: { organization: marker }, + }), + ); + const role = yield* refused( + "role-change", + "member.role_change", + post("/api/auth/organization/update-member-role", { + memberId: marker, + organizationId: marker, + role: "admin", + }), + ); + // Nothing but the product analytics plugin's after hook reads this endpoint's + // session, and it runs before the audit plugin's because it is registered first. + // Reordering the plugins, or removing that read, leaves the record without an actor. + expect( + auditRecord(role, "member.role_change", [ + "audit.target.id", + "executor.organization.id", + "executor.user.id", + "auth.credential.kind", + "auth.credential.id", + ]), + "A refused role change names no member or organization, but the session that asked", + ).toEqual({ + "audit.target.id": undefined, + "executor.organization.id": undefined, + "executor.user.id": member.id, + "auth.credential.kind": "session", + "auth.credential.id": memberSession.session.id, + }); + yield* refused( + "invitation", + "invitation.create", + post("/api/auth/organization/invite-member", { + email: `security-observability-${randomUUID().slice(0, 8)}@example.test`, + organizationId: marker, + role: "member", + }), + ); + yield* refused( + "agent-revoke", + "mcp_agent.revoke", + post(`${organizations}/mcp-agents/${marker}/revoke`, {}), + ); + yield* refused( + "connection-revoke", + "mcp_connection.revoke", + post(`${organizations}/mcp-connections/${marker}/revoke`, {}), + ); + // Better Auth returns a provider's `error` to the browser in its error redirect. + const browser = yield* api.session(); + const started = yield* api.request(browser, "POST", "/api/auth/sign-in/social", { + provider: "github", + callbackURL: "/login", + errorCallbackURL: "/login", + }); + expect(started.status, JSON.stringify(started.body)).toBe(200); + const authorization = new URL( + Redacted.value((yield* body(SocialStart, started)).url), + ); + const redirect = authorization.searchParams.get("redirect_uri"); + const state = authorization.searchParams.get("state"); + expect(redirect, "The sign-in names its callback").toBeTypeOf("string"); + expect(state, "and its state").toBeTypeOf("string"); + const callback = new URL(redirect ?? ""); + callback.searchParams.set("state", state ?? ""); + callback.searchParams.set("error", marker); + const social = yield* refused("social-error", "sign_in", (traceparent) => + api + .request(browser, "GET", `${callback.pathname}${callback.search}`, undefined, { + traceparent, + }) + // The redirect opens the sign-in page, which is not JSON. + .pipe(Effect.exit), + ); + expect(social, "The provider's error is recorded as an unknown code").toMatch( + /"audit\.failure":\s*"other"/u, + ); + expect(social, "of a social sign-in").toContain("social"); + }), + ); + + yield* evidence.step( + "A consent records the decision the endpoint took, whatever else the body holds", + Effect.gen(function* () { + const redirect = "http://127.0.0.1:9/callback"; + const registered = yield* api.request( + anonymous, + "POST", + "/api/auth/oauth2/register", + { + client_name: "Security observability consent", + redirect_uris: [redirect], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }, + ); + expect(registered.status, JSON.stringify(registered.body)).toBe(201); + const { client_id: clientId } = yield* body( + Schema.Struct({ client_id: Schema.String }), + registered, + ); + const query = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: redirect, + code_challenge: createHash("sha256") + .update(randomBytes(32).toString("base64url")) + .digest("base64url"), + code_challenge_method: "S256", + scope: "mcp", + state: randomBytes(16).toString("hex"), + }); + const opened = yield* api.request( + actors.member, + "GET", + `/api/auth/oauth2/authorize?${query.toString()}`, + undefined, + { accept: "application/json" }, + ); + expect(opened.status, JSON.stringify(opened.body)).toBe(200); + const consentPage = new URL( + (yield* body(Schema.Struct({ url: Schema.String }), opened)).url, + "https://consent.invalid", + ); + const denied = traced(); + const denial = yield* api.request( + actors.member, + "POST", + "/api/auth/oauth2/consent", + // `role` is no consent field; Better Auth ignores it, and so must the record. + { accept: false, oauth_query: consentPage.search.slice(1), role: 42 }, + { traceparent: denied.traceparent }, + ); + expect(denial.status, JSON.stringify(denial.body)).toBe(200); + expect( + auditRecord(yield* audit(denied.traceId, "oauth.consent"), "oauth.consent", [ + "audit.outcome", + "audit.decision", + "audit.target.id", + ]), + "A denial is recorded as one, naming the client", + ).toEqual({ + "audit.outcome": "success", + "audit.decision": "deny", + "audit.target.id": clientId, + }); + }), + ); + + yield* evidence.step( + "A sign-out is recorded where its session is deleted, and only then as revoked", + Effect.gen(function* () { + const session = yield* freshOwnerSession; + const signedIn = yield* body( + SessionIdentity, + yield* api.request(session, "GET", "/api/auth/get-session"), + ); + const out = traced(); + const signedOut = yield* api.request( + session, + "POST", + "/api/auth/sign-out", + {}, + { + traceparent: out.traceparent, + }, + ); + expect(signedOut.status, JSON.stringify(signedOut.body)).toBe(200); + const revoked = yield* audit(out.traceId, "session.revoke"); + expect(revoked, "The deletion is a success").toMatch( + /"audit\.outcome":\s*"success"/u, + ); + expect(revoked, "naming the deleted session").toContain(signedIn.session.id); + expect(revoked, "and its user").toContain(signedIn.user.id); + // A sign-out without a session deletes nothing, though Better Auth answers success. + const nothing = traced(); + const empty = yield* api.request( + anonymous, + "POST", + "/api/auth/sign-out", + {}, + { + traceparent: nothing.traceparent, + }, + ); + expect(empty.status, JSON.stringify(empty.body)).toBe(200); + expect( + yield* audit(nothing.traceId, "session.revoke"), + "A sign-out that deleted no session is unconfirmed", + ).toMatch(/"audit\.outcome":\s*"unconfirmed"/u); + }), + ); + + yield* evidence.step( + "A handler's log arrives from successive and concurrent requests", + Effect.gen(function* () { + // `security.audit` is an ordinary log a handler writes with its request's loggers. + const revoke = Effect.gen(function* () { + const { traceId, traceparent } = traced(); + const response = yield* api.request( + actors.member, + "POST", + `/api/organizations/${actors.organization.slug}/mcp-agents/agent-${randomUUID()}/revoke`, + {}, + { traceparent }, + ); + expect(response.status, JSON.stringify(response.body)).toBe(404); + return traceId; + }); + const successive = yield* Effect.forEach([1, 2, 3], () => revoke); + const concurrent = yield* Effect.all([revoke, revoke, revoke, revoke], { + concurrency: "unbounded", + }); + for (const traceId of [...successive, ...concurrent]) + expect(yield* audit(traceId, "mcp_agent.revoke")).toContain(member.id); + }), + ); + }), + ), + { timeout: 180_000 }, + ); +}); diff --git a/packages/apps/package.json b/packages/apps/package.json index 16d4c49f54..e3b44da571 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -1,6 +1,6 @@ { "name": "apps", - "version": "0.0.1-beta.120", + "version": "0.0.1-beta.121", "private": true, "description": "TypeScript apps with typed tools, accounts, storage, and UI.", "type": "module", diff --git a/packages/mcp-auth/src/implementation/oauth.ts b/packages/mcp-auth/src/implementation/oauth.ts index f5efaf776a..467bc21f25 100644 --- a/packages/mcp-auth/src/implementation/oauth.ts +++ b/packages/mcp-auth/src/implementation/oauth.ts @@ -1250,7 +1250,8 @@ export const grantOAuthPlugins = (settings: GrantOAuthOptions) => { Effect.gen(function* () { const row = yield* ownedConnection(ctx, ctx.body, ctx.body.id); yield* revokeConnection(ctx, row); - return { revoked: true }; + // The stored connection's ID, for callers that record which one was revoked. + return { revoked: true, id: row.id }; }), ), ), @@ -1276,7 +1277,7 @@ export const grantOAuthPlugins = (settings: GrantOAuthOptions) => { if (row.userId !== ctx.body.userId || row.resource !== ctx.body.resource) return yield* Effect.fail(new APIError("NOT_FOUND")); yield* revoke(ctx, row.id); - return { revoked: true }; + return { revoked: true, id: row.id }; }), ), ), diff --git a/packages/sdk/src/implementation/oauth.ts b/packages/sdk/src/implementation/oauth.ts index d2ad7e9c63..976186b883 100644 --- a/packages/sdk/src/implementation/oauth.ts +++ b/packages/sdk/src/implementation/oauth.ts @@ -1,4 +1,4 @@ -import type { AppAccountUse, ResourceLifecycle } from "../contracts/executor.ts"; +import { CurrentActor, type AppAccountUse, type ResourceLifecycle } from "../contracts/executor.ts"; import type { BackgroundWork } from "../contracts/declarations.ts"; /** Trusted OAuth lifecycle. Provider definitions never contain client secrets or saved grants. */ import { parseDestination, parseEndpoint, httpsOnlyUrlPolicy } from "@executor-js/utils/url-policy"; @@ -335,6 +335,31 @@ type CredentialUse = | { readonly kind: "run"; readonly app: { readonly id: AppId; readonly owner: OwnerId } } | { readonly kind: "removal"; readonly app: { readonly id: AppId; readonly owner: OwnerId } } | { readonly kind: "check" }; +/** + * Why credentials are decrypted: a credential use, or `resolve` for a caller that names none. + * `oauth.resolve` records it as `oauth.resolve.purpose`. + */ +type CredentialPurpose = CredentialUse["kind"] | "resolve"; +/** + * What an `oauth.resolve` span records about the credentials it decrypts: the account, the + * organization that owns it, the actor it is decrypted for and why. All opaque IDs or closed + * labels, read without I/O; never the credentials. + */ +const credentialAccess = ( + purpose: CredentialPurpose, + account: StoredAccount, + provider: ProviderDefinition, + actor: string | undefined, +) => ({ + "oauth.provider.id": account.provider, + "oauth.resolve.purpose": purpose, + "oauth.resolve.credential_type": provider.auth[account.method]?.type ?? "unknown", + "executor.account.id": account.id, + ...(account.owner.startsWith("organization:") + ? { "executor.organization.id": account.owner.slice("organization:".length) } + : {}), + ...(actor === undefined ? {} : { "executor.user.id": actor }), +}); /** * How an evaluation uses one account: whether a user requirement uses it (`user`), and the app * requirements it fills (`appUses`). Each use is authorized on its own; see `ResourceLifecycle`. @@ -2337,13 +2362,16 @@ export const makeOAuth = ( * it is returned as a value and recorded as the span's outcome, and only failed after the span. */ const resolveCredentials = ( + purpose: CredentialPurpose, account: StoredAccount, provider: ProviderDefinition, resolution: Resolution, rejected?: JsonObject, ) => Effect.gen(function* () { - yield* Effect.annotateCurrentSpan("oauth.provider.id", account.provider); + yield* Effect.annotateCurrentSpan( + credentialAccess(purpose, account, provider, yield* CurrentActor), + ); if (rejected !== undefined) yield* Effect.annotateCurrentSpan("oauth.renewal.trigger", "credentials_rejected"); // Secrets the user brought. @@ -2865,6 +2893,7 @@ export const makeOAuth = ( * `resolution` records whether it did. */ const resolveAuthorized = ( + purpose: CredentialPurpose, account: StoredAccount, provider: ProviderDefinition, allowed: ReadonlySet | undefined, @@ -2877,14 +2906,14 @@ export const makeOAuth = ( ) => Effect.gen(function* () { if (allowed !== undefined && !allowed.has(account.id)) return yield* new AccountUseRefused(); - const fields = yield* resolveCredentials(account, provider, resolution, rejected); + const fields = yield* resolveCredentials(purpose, account, provider, resolution, rejected); // A remote token refresh can outlive a permission change, an unlinking or account deletion. if (resolution.contested) yield* recheck; return fields; }); const resolve = (account: StoredAccount, provider: ProviderDefinition, rejected?: JsonObject) => Effect.flatMap(authorized([account]), (allowed) => - resolveAuthorized(account, provider, allowed, { contested: false }, rejected), + resolveAuthorized("resolve", account, provider, allowed, { contested: false }, rejected), ).pipe( // A refusal is the storage failure callers that do not distinguish it have always seen. Effect.catchTag("AccountUseRefused", () => Effect.fail(new StorageError())), @@ -2976,6 +3005,7 @@ export const makeOAuth = ( return yield* Effect.forEach(selected, ({ account, provider }) => Effect.gen(function* () { const credential = yield* resolveAuthorized( + use.kind, account, provider, checked, @@ -3030,7 +3060,15 @@ export const makeOAuth = ( ) => { const recheck = selectionAuthorized(use, selection); return Effect.flatMap(recheck, () => - resolveAuthorized(account, provider, undefined, { contested: false }, rejected, recheck), + resolveAuthorized( + use.kind, + account, + provider, + undefined, + { contested: false }, + rejected, + recheck, + ), ).pipe( // A refusal is the storage failure callers that do not distinguish it have always seen. Effect.catchTag("AccountUseRefused", () => Effect.fail(new StorageError())), diff --git a/packages/telemetry/src/http.ts b/packages/telemetry/src/http.ts index cfb1dc8720..740292e914 100644 --- a/packages/telemetry/src/http.ts +++ b/packages/telemetry/src/http.ts @@ -89,3 +89,5 @@ export const receiveBrowserTelemetry = (signal: "traces" | "logs", build?: strin TimeoutError: () => Effect.succeed(HttpServerResponse.empty({ status: 504 })), }), ); +export { annotateRequestSpan, currentRequestSpan } from "./request-span.ts"; +export { toRequestHttpEffect } from "./request-router.ts"; diff --git a/packages/telemetry/src/request-router.ts b/packages/telemetry/src/request-router.ts new file mode 100644 index 0000000000..141cd39f8f --- /dev/null +++ b/packages/telemetry/src/request-router.ts @@ -0,0 +1,20 @@ +/** + * Build an HTTP router whose handlers log with the loggers of the request they serve. + * + * `HttpApiBuilder` keeps the context a group's routes are built in and provides it to every + * handler call, over the request's own. A logger in that context would replace the request's: + * a Cloudflare Worker builds its routes during initialization, whose context holds Alchemy's + * console logger, so every record a handler wrote went to the console only and never reached the + * log exporter the request's telemetry provides. Routes are therefore built without the loggers + * of the context that builds them; each handler call keeps the loggers its request runs with. + */ +import { Context, Effect, Logger, type Layer } from "effect"; +import { HttpRouter } from "effect/http"; + +/** `HttpRouter.toHttpEffect`, building the routes without the builder's loggers. */ +export const toRequestHttpEffect = (appLayer: Layer.Layer) => { + const build = HttpRouter.toHttpEffect(appLayer); + return Effect.updateContext(build, (context: Context.Context>) => + Context.omit(Logger.CurrentLoggers)(context), + ); +}; diff --git a/packages/telemetry/src/request-span.ts b/packages/telemetry/src/request-span.ts new file mode 100644 index 0000000000..dfd3ce8b70 --- /dev/null +++ b/packages/telemetry/src/request-span.ts @@ -0,0 +1,40 @@ +/** + * The request's span: the span that stands for one request in the Worker or process serving it. + * Facts about the whole request, such as who made it and why it was refused, belong there, where a + * query of requests by status, route and host finds them. Code deep inside a request runs in child + * spans (`auth.authenticate`, a handler's own spans), so it names the request's span explicitly + * instead of annotating its current one. + * + * That span is the nearest ancestor of kind `server` (the HTTP server tracer's span, or a remote + * call's), else the outermost span this process started, such as a batched dashboard read's + * (`dashboard.batch.read`), whose parent is the browser's. + */ +import { Context, Effect, Option, Tracer } from "effect"; + +const requestSpanOf = (context: Context.Context): Tracer.Span | undefined => { + let span: Tracer.AnySpan | undefined = Option.getOrUndefined( + Context.getOption(context, Tracer.ParentSpan), + ); + let outermost: Tracer.Span | undefined; + while (span !== undefined && span._tag === "Span") { + if (span.kind === "server") return span; + outermost = span; + span = Option.getOrUndefined(span.parent); + } + return outermost; +}; + +/** Set attributes on the request's span. Outside any span this does nothing. */ +export const annotateRequestSpan = (attributes: Readonly>) => + Effect.withFiber((fiber) => + Effect.sync(() => { + const span = requestSpanOf(fiber.context); + if (span === undefined) return; + for (const [key, value] of Object.entries(attributes)) span.attribute(key, value); + }), + ); + +/** The request's span, to hold on to beyond the current fiber's span. */ +export const currentRequestSpan: Effect.Effect> = Effect.withFiber( + (fiber) => Effect.succeed(Option.fromNullishOr(requestSpanOf(fiber.context))), +); diff --git a/packages/telemetry/src/span-attributes.ts b/packages/telemetry/src/span-attributes.ts index c16aa0f398..c2b5a8dbc8 100644 --- a/packages/telemetry/src/span-attributes.ts +++ b/packages/telemetry/src/span-attributes.ts @@ -48,6 +48,9 @@ export const httpSpanAttributeAllowlist: ReadonlySet = new Set([ "http.response.header.content-length", // The server tracer records the user agent a second time under its own name. "user_agent.original", + // The server tracer's remote address: the peer the host saw, or on Cloud the address + // Cloudflare saw (`cf-connecting-ip`, which Cloudflare overwrites on every incoming request). + "client.address", ]); /** Record this attribute? HTTP names must be allowlisted; product names pass through. */ From 5348aad46d133dff2730d87b9640ac9310cdbece Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:34:42 +0000 Subject: [PATCH 4/9] Export 4d94f0c8 --- .changeset/mcp-structured-copy.md | 9 ++ apps/docs/content/mcp.md | 4 +- e2e/test-plan.ts | 10 ++ e2e/tests/mcp-direct-calls.spec.ts | 115 +++++++++++++++++- .../skills/app-authoring/integrations.md | 3 +- .../executor-skill-digests.gen.ts | 2 +- packages/apps/package.json | 2 +- packages/apps/src/implementation/mcp-call.ts | 24 ++++ 8 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-structured-copy.md diff --git a/.changeset/mcp-structured-copy.md b/.changeset/mcp-structured-copy.md new file mode 100644 index 0000000000..8c080183eb --- /dev/null +++ b/.changeset/mcp-structured-copy.md @@ -0,0 +1,9 @@ +--- +"apps": patch +"@executor-js/app-templates": patch +--- + +Remote MCP results drop a text block that only repeats `structuredContent` as +JSON. Servers send that copy for clients that cannot read structured results; +kept, it doubled the result at every nested MCP hop, such as Executor behind +Executor. Read `structuredContent`; other content is unchanged. diff --git a/apps/docs/content/mcp.md b/apps/docs/content/mcp.md index 22f2abb3db..29fbc01700 100644 --- a/apps/docs/content/mcp.md +++ b/apps/docs/content/mcp.md @@ -88,7 +88,9 @@ needs. That is the point: the whole result set never has to pass through the model's context. A remote MCP tool returns its whole MCP result: `content`, `structuredContent` -and `isError`. `isError: true` is the tool saying it failed. The call returns +and `isError`. A server that returns `structuredContent` usually repeats it as +JSON text in `content`; Executor drops that copy, so read `structuredContent`. +Other content stays as the server sent it. `isError: true` is the tool saying it failed. The call returns that result instead of throwing, so programs that read `isError` keep working, and the result's `toolCalls` lists the call with outcome `failure`. The server may still have made changes. `call` lists it the same way. An outcome of diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index d5c755474c..93cebcc660 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -938,6 +938,16 @@ export const scenarios = plan({ local: na("Hosted version API scenario"), }, }, + mcpNestedExecutorResult: { + fixtures: "actors", + file: "mcp-direct-calls.spec.ts", + title: "Executor behind Executor returns each nested result once", + targets: { + "self-host": scheduled, + cloud: na("Loopback MCP router; covered on self-host"), + local: na("Hosted version API scenario"), + }, + }, mcpNativeCallApproval: { fixtures: "actors", file: "mcp-direct-calls.spec.ts", diff --git a/e2e/tests/mcp-direct-calls.spec.ts b/e2e/tests/mcp-direct-calls.spec.ts index c0baac4e49..2e90c68872 100644 --- a/e2e/tests/mcp-direct-calls.spec.ts +++ b/e2e/tests/mcp-direct-calls.spec.ts @@ -1,15 +1,17 @@ /** Search, describe and call reach app tools through MCP without an execute program. */ import { expect, layer } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect, Redacted, Schema } from "effect"; import { randomUUID } from "node:crypto"; import { scenarios } from "../test-plan.ts"; import { Api, body } from "../support/api.ts"; import { Actors } from "../support/actors.ts"; -import { App } from "../support/contracts.ts"; +import { App, Resource } from "../support/contracts.ts"; +import { connectionsPath } from "../support/app-accounts.ts"; import { Evidence } from "../support/evidence.ts"; import { HostedLive, withHostedCase } from "../support/case.ts"; import { McpClient } from "../support/mcp-client.ts"; import { appsManifest } from "../support/apps-release.ts"; +import { Target } from "../support/platform.ts"; const source = `import { defineApp, mutation, query, object, string, router } from "apps"; import { always } from "apps/operations/approval"; @@ -229,4 +231,113 @@ layer(HostedLive, { excludeTestServices: true })("MCP direct calls", (it) => { }).pipe(Effect.provide(McpClient.layer)), ), ); + + it.effect(scenarios.mcpNestedExecutorResult.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + mcp = yield* McpClient, + evidence = yield* Evidence; + const origin = (yield* Target).metadata.origin; + const { key, app, organization } = yield* fixture; + const prefix = `/api/organizations/${organization}`; + // An app that reaches this same Executor through its MCP server, as a remote MCP router. + // Its account holds the access token the inner Executor accepts. + const name = `Nested executor ${randomUUID().slice(0, 8)}`; + const deployed = yield* api.request(actors.owner, "POST", `${prefix}/apps/deploy`, { + name, + files: [ + { + path: "index.ts", + content: `import { defineApp, defineProvider, object, secrets, string } from "apps"; +import { mcpRouter } from "apps/mcp"; +const service = defineProvider({ name: ${JSON.stringify(name)}, hosts: [${JSON.stringify(new URL(origin).host)}], auth: { + key: secrets({ label: "Key", fields: object({ token: string() }) }), +} }); +export default defineApp({ user: { accounts: { service } } }, async ({ user: { accounts }, signal }) => ({ + tools: await mcpRouter({ + url: ${JSON.stringify(`${origin}/mcp`)}, + account: accounts.service, + headers: { Authorization: "Bearer " + accounts.service.fields.token, "X-Executor-Organization": ${JSON.stringify(organization)} }, + signal, + }), +}));`, + }, + appsManifest, + ], + }); + expect(deployed.status, JSON.stringify(deployed.body)).toBe(200); + const outer = yield* body(App, deployed); + const outerPath = `${prefix}/apps/${outer.id}`; + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", outerPath).pipe(Effect.orDie), + ); + const connection = yield* body( + Resource, + yield* api.request( + actors.owner, + "POST", + connectionsPath(outerPath, "user", "service"), + {}, + ), + ); + const saved = yield* api.request( + actors.owner, + "POST", + `${prefix}/connections/${connection.id}/submit`, + { method: "key", label: "Inner Executor", fields: { token: Redacted.value(key.key) } }, + ); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + const account = (yield* body(Resource, saved)).id; + yield* Effect.addFinalizer(() => + api.request(actors.owner, "DELETE", `${prefix}/accounts/${account}`).pipe(Effect.orDie), + ); + + const client = yield* mcp.connect(key.key, "nested-executor", { organization }); + const echo = `tools[${JSON.stringify(app.slug)}].echo`; + const nestedCall = `tools[${JSON.stringify(outer.slug)}].call`; + const id = randomUUID(); + const marker = `say "nested" ${id}`; + const result = yield* client.use("Call Executor through Executor", (client, signal) => + client.callTool( + { + name: "call", + arguments: { + calls: { + path: nestedCall, + input: { calls: { path: echo, input: { text: marker } } }, + }, + }, + }, + undefined, + { signal }, + ), + ); + yield* evidence.json("nested-executor-result.json", result); + const completed = yield* Schema.decodeUnknownEffect(Completed)(result.structuredContent); + // The inner Executor's text block only repeats its structured result, so the outer + // Executor keeps the structured result alone. + expect(completed.execution).toMatchObject({ + ok: true, + value: { + path: nestedCall, + ok: true, + value: { + content: [], + structuredContent: { + status: "completed", + execution: { ok: true, value: { path: echo, ok: true, value: { text: marker } } }, + }, + }, + }, + }); + // Each hop carries the payload once, so nesting does not multiply the agent's context. + const nested = JSON.stringify(completed.execution.value); + expect(nested.split(id).length - 1).toBe(1); + expect(nested).toContain(JSON.stringify(marker)); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); }); diff --git a/packages/app-templates/executor/skills/app-authoring/integrations.md b/packages/app-templates/executor/skills/app-authoring/integrations.md index c22acc6db2..d6a3907276 100644 --- a/packages/app-templates/executor/skills/app-authoring/integrations.md +++ b/packages/app-templates/executor/skills/app-authoring/integrations.md @@ -211,7 +211,8 @@ are generated with this check. Streamable HTTP and legacy SSE are supported. Every discovery/call owns and closes its connection. Session-local workflows do not survive separate tool -calls. Results retain MCP `content`, `structuredContent`, `isError`, and `_meta`; +calls. Results retain MCP `content`, `structuredContent`, `isError`, and `_meta`, +except a text block that only repeats `structuredContent` as JSON, which is dropped; remote tool failures are results, while transport failures reject the call. A tool's output type describes that whole result, with the server's output schema under `structuredContent`, and every result is checked against it. Read typed diff --git a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts index f1171487b7..f424068aa3 100644 --- a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts +++ b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts @@ -6,7 +6,7 @@ export const executorSkillDigests: Readonly> = { "app-authoring/accounts.md": "a0ac083c813faa53739f3ea1af54dbb7b8467f51237dc167b46f0b829ad539ab", "app-authoring/deploy.md": "2df76fb22f6b0b04032c28c3b589199ca1aea91fd36548944ce797d32f8154af", "app-authoring/events.md": "8f7465e35c6c94b06978b89680181df60c741225de3ba8158f22f76ee7e91a5a", - "app-authoring/integrations.md": "3f9cecb591465a71c95c98046dab960750000c05a900fde3550aac4ea3a0171a", + "app-authoring/integrations.md": "8cebbe564eb0ed589be0894ccd3765941f3d80f0aea2db434fd9c99ce21135da", "app-authoring/starter.md": "382bd769d7b10e9c09eaa2367755f23153a31a484aaca9c449ab4aac7d61e224", "app-authoring/storage.md": "d2c2288c8a6dafa0a88f09e7e91409d9afc08158205ea6d25064d0dee67a33df", "app-authoring/tools.md": "460eed5aaf4f84071945f891b955765a39d5372773cb6fff4f4e5d0d99e68a14", diff --git a/packages/apps/package.json b/packages/apps/package.json index e3b44da571..86b3047d4d 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -1,6 +1,6 @@ { "name": "apps", - "version": "0.0.1-beta.121", + "version": "0.0.1-beta.122", "private": true, "description": "TypeScript apps with typed tools, accounts, storage, and UI.", "type": "module", diff --git a/packages/apps/src/implementation/mcp-call.ts b/packages/apps/src/implementation/mcp-call.ts index 5633812466..f7cc60fa2a 100644 --- a/packages/apps/src/implementation/mcp-call.ts +++ b/packages/apps/src/implementation/mcp-call.ts @@ -62,6 +62,29 @@ function callBudget(timeoutMs: number) { }); } +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json)); + +/** + * The MCP spec asks a server that returns `structuredContent` to repeat it as a JSON text block + * for clients that cannot read structured results. Executor reads `structuredContent`, so that + * copy only doubles the result each time an MCP hop is nested, such as Executor behind Executor. + * Drop text blocks whose JSON equals `structuredContent`; any other content is kept as sent. + */ +const withoutStructuredCopy = (result: McpToolResult): McpToolResult => { + const { structuredContent } = result; + if (structuredContent === undefined) return result; + const structured = JSON.stringify(structuredContent); + const content = result.content.filter( + (block) => + !( + block.type === "text" && + typeof block.text === "string" && + Option.exists(decodeJsonText(block.text), (value) => JSON.stringify(value) === structured) + ), + ); + return content.length === result.content.length ? result : { ...result, content }; +}; + /** Register prompts only around tools/call, preserve metadata, and close every callback with the call. Never retries. */ export const mcpCall = ( client: Client, @@ -149,6 +172,7 @@ export const mcpCall = ( Effect.flatMap((result) => Schema.decodeUnknownEffect(McpToolResult)(result).pipe( Effect.mapError(() => new McpError({ phase: "call", reason: "invalid_response" })), + Effect.map(withoutStructuredCopy), ), ), Effect.tap((result) => From f80a5266cebf4af2f25d43b443164b57abbb3bbc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:19:12 +0000 Subject: [PATCH 5/9] Export 4392bfb6 --- apps/cli/CHANGELOG.md | 4 ++ apps/cli/package.json | 2 +- bun.lock | 2 +- patches/alchemy.md | 8 ++++ patches/alchemy@2.0.0-beta.80.patch | 72 +++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 6cc0744715..f8f0db9141 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # executor +## 2.0.0-beta.38 + +Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. + ## 2.0.0-beta.37 Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. diff --git a/apps/cli/package.json b/apps/cli/package.json index 1e0fbffbc2..866e7cec00 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "2.0.0-beta.37", + "version": "2.0.0-beta.38", "private": true, "description": "Run Executor locally and connect your agents to your apps.", "homepage": "https://executor.sh", diff --git a/bun.lock b/bun.lock index 84da374ec9..ae33eebed6 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ }, "apps/cli": { "name": "executor", - "version": "2.0.0-beta.37", + "version": "2.0.0-beta.38", }, "apps/docs": { "name": "@executor-js/docs", diff --git a/patches/alchemy.md b/patches/alchemy.md index 821eceded0..52126df15e 100644 --- a/patches/alchemy.md +++ b/patches/alchemy.md @@ -59,6 +59,14 @@ and the Bun TypeScript entry points (`src/`). It carries: time it peaks at 4-4.7 GB and starts as fast. Rebuilds after an edit and deploy builds (`Bundle.build`) do not wait. Drop this part when alchemy bounds concurrent first builds itself. +- **State store probe.** A `/version` probe of the Cloudflare state store + that still fails after its retries, or a failed workers.dev subdomain + lookup, fails the run. beta.80 read either as "no store", and with `--yes` + (every deploy and preview job) bootstrapped a new store over the live one. + That replaced `AlchemyStateStoreEncryptionKey` and made every stored entry + unreadable. The store now counts as missing only when the probe gets a 404 + and Cloudflare reports no `alchemy-state-store` Worker. Upstream beta.85 + behaves the same way; drop this part when upgrading to it. Upstream beta.80 now provides what the beta.79 patch also carried: storing a no-op resource before signalling dependents diff --git a/patches/alchemy@2.0.0-beta.80.patch b/patches/alchemy@2.0.0-beta.80.patch index 2e741723ba..03fabdcc52 100644 --- a/patches/alchemy@2.0.0-beta.80.patch +++ b/patches/alchemy@2.0.0-beta.80.patch @@ -2033,3 +2033,75 @@ diff --git a/src/Bundle/Bundle.ts b/src/Bundle/Bundle.ts ).pipe( Stream.mapEffect((event) => Effect.gen(function* () { +diff --git a/lib/Cloudflare/StateStore/State.js b/lib/Cloudflare/StateStore/State.js +index eedcce0da9510202685455fb8fa4735f670173a6..7c1a2fb160bf7163a7c032a08719c9a4a7abce60 100644 +--- a/lib/Cloudflare/StateStore/State.js ++++ b/lib/Cloudflare/StateStore/State.js +@@ -611,7 +611,7 @@ const isStateStoreAvailable = (scriptName = "alchemy-state-store") => Effect.gen + const isStateStoreServing = (accountId) => Effect.gen(function* () { + const url = yield* workers.getSubdomain({ accountId }).pipe(Effect.map(({ subdomain }) => subdomain + ? `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev` +- : undefined), Effect.catch(() => Effect.succeed(undefined))); ++ : undefined)); + if (url === undefined) + return false; + const { observed } = yield* checkStateStoreVersion(url); +@@ -664,9 +664,9 @@ const checkStateStoreVersion = (url) => Effect.gen(function* () { + // The /version route may 404 transiently after a fresh deploy + // while Cloudflare propagates the new script to the edge, and may + // also surface transport-level blips on cold workers.dev hosts. +- // Retry the probe itself for ~10s before giving up — only after +- // exhausting that budget do we collapse to `undefined` and let +- // the caller treat it as a version mismatch. ++ // Retry the probe itself for ~10s before giving up. `observed` is ++ // `undefined` only when the worker is confirmed absent; a probe that ++ // still fails after that budget fails the check. + const result = yield* client.version.getVersion().pipe(Effect.catchTag("HttpClientError", (e) => + // if we get a 404 here, it means we assumed the worker shoudl exist, but it does not + // we should do a check to see if it does +@@ -680,7 +680,7 @@ const checkStateStoreVersion = (url) => Effect.gen(function* () { + Schedule.spaced("250 millis"), + Schedule.recurs(40), + ]), +- }), Effect.catch(() => Effect.succeed(undefined))); ++ })); + const matches = result?.version === STATE_STORE_VERSION; + yield* Effect.annotateCurrentSpan({ + "alchemy.state_store.expected_version": STATE_STORE_VERSION, +diff --git a/src/Cloudflare/StateStore/State.ts b/src/Cloudflare/StateStore/State.ts +index 195e301897bdcb118592206f57043045269d61c3..20fc3b04120a131aeb710b390e4d5284c8f74b3b 100644 +--- a/src/Cloudflare/StateStore/State.ts ++++ b/src/Cloudflare/StateStore/State.ts +@@ -906,7 +906,8 @@ const isStateStoreServing = (accountId: string) => + ? `https://${STATE_STORE_SCRIPT_NAME}.${subdomain}.workers.dev` + : undefined, + ), +- Effect.catch(() => Effect.succeed(undefined)), ++ // A failed lookup is not proof that no store exists; let it fail the ++ // run instead of bootstrapping over the live store's secrets. + ); + if (url === undefined) return false; + const { observed } = yield* checkStateStoreVersion(url); +@@ -979,9 +980,11 @@ const checkStateStoreVersion = (url: string) => + // The /version route may 404 transiently after a fresh deploy + // while Cloudflare propagates the new script to the edge, and may + // also surface transport-level blips on cold workers.dev hosts. +- // Retry the probe itself for ~10s before giving up — only after +- // exhausting that budget do we collapse to `undefined` and let +- // the caller treat it as a version mismatch. ++ // Retry the probe itself for ~10s before giving up. `observed` is ++ // `undefined` only when the worker is confirmed absent; a probe that ++ // still fails after that budget fails the check. Treating it as ++ // absent made `--yes` bootstrap a new store over the live one, ++ // replacing the encryption key every stored entry depends on. + const result = yield* client.version.getVersion().pipe( + Effect.catchTag("HttpClientError", (e) => + // if we get a 404 here, it means we assumed the worker shoudl exist, but it does not +@@ -1002,7 +1005,6 @@ const checkStateStoreVersion = (url: string) => + Schedule.recurs(40), + ]), + }), +- Effect.catch(() => Effect.succeed(undefined)), + ); + const matches = result?.version === STATE_STORE_VERSION; + yield* Effect.annotateCurrentSpan({ From c5b7afe07974902378f9b431f7cdefa1a7eaf454 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:19:35 +0000 Subject: [PATCH 6/9] Export c86dfea3 --- patches/alchemy.md | 15 +++ patches/alchemy@2.0.0-beta.80.patch | 164 +++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/patches/alchemy.md b/patches/alchemy.md index 52126df15e..f70e16b65e 100644 --- a/patches/alchemy.md +++ b/patches/alchemy.md @@ -67,6 +67,21 @@ and the Bun TypeScript entry points (`src/`). It carries: unreadable. The store now counts as missing only when the probe gets a 404 and Cloudflare reports no `alchemy-state-store` Worker. Upstream beta.85 behaves the same way; drop this part when upgrading to it. +- **State recovery.** `ALCHEMY_RECOVER_INSTANCE_IDS` is a JSON object from + `//` to an instance ID. Where Alchemy would mint an ID + for a resource that has no state (the cold-start adoption read and the + create), it uses that one instead. A generated physical name ends with the + first 16 base32 characters of its resource's instance ID, so a deploy whose + records were lost can name every resource exactly as before and adopt it. + Without the IDs it creates new Workers beside the live ones, and their new + Durable Object namespaces are empty. Derive each ID from the live name by + decoding those 16 characters (80 bits) and padding with zero bytes; accept + it only when recomputing the name from the stack, logical ID, stage and + suffix gives the live name exactly. Pass it through the Deploy workflow's + `recover_instance_ids` input after deleting the unreadable records, which + also adds `--adopt`. Resources Alchemy cannot read back are created again: + `Random` values, PlanetScale roles, Axiom API tokens and monitors. Remove + this part once no stage needs recovering. Upstream beta.80 now provides what the beta.79 patch also carried: storing a no-op resource before signalling dependents diff --git a/patches/alchemy@2.0.0-beta.80.patch b/patches/alchemy@2.0.0-beta.80.patch index 03fabdcc52..8af09d4486 100644 --- a/patches/alchemy@2.0.0-beta.80.patch +++ b/patches/alchemy@2.0.0-beta.80.patch @@ -12,10 +12,16 @@ index 8d4f9b29e6d845414e76fc709c0ae26206c17af5..a26e84f0593159d30a8c77625b7cd62d export function Action(...args) { if (args.length === 0) { diff --git a/lib/Apply.js b/lib/Apply.js -index 22a223eca67c519d34f7ced0f6cb9b2b85f53f14..1f37fdfec7d6c35a242ddea7a0f8d54b5e46025a 100644 +index 22a223eca67c519d34f7ced0f6cb9b2b85f53f14..00224cced63882747b3672837035ede1c217a6b0 100644 --- a/lib/Apply.js +++ b/lib/Apply.js -@@ -18,7 +18,7 @@ import * as Output from "./Output.js"; +@@ -13,12 +13,12 @@ import { RuntimeContext } from "./RuntimeContext.js"; + import { Artifacts, ArtifactStore, createArtifactStore, ensureArtifactStore, makeScopedArtifacts, } from "./Artifacts.js"; + import { Cli, noopSession, } from "./Report.js"; + import { havePropsChanged, stripUnresolved } from "./Diff.js"; +-import { generateInstanceId, InstanceId } from "./InstanceId.js"; ++import { generateInstanceId, InstanceId, newInstanceId } from "./InstanceId.js"; + import * as Output from "./Output.js"; import {} from "./Plan.js"; import { findProviderByType, missingProviderError, tryFindProviderByType, } from "./Provider.js"; import { stampedMode } from "./ProviderMode.js"; @@ -24,6 +30,15 @@ index 22a223eca67c519d34f7ced0f6cb9b2b85f53f14..1f37fdfec7d6c35a242ddea7a0f8d54b import { Stage } from "./Stage.js"; import { State, StateStoreError, } from "./State/index.js"; import { recordResourceOp } from "./Telemetry/Metrics.js"; +@@ -411,7 +411,7 @@ const executeNode = (fqn, node, tracker, ready, readyStable, terminalStatuses, s + // ── instance ID ── + const instanceId = yield* Effect.gen(function* () { + if (node.action === "create" && !node.state?.instanceId) { +- const id = yield* generateInstanceId(); ++ const id = yield* newInstanceId(fqn); + yield* commit({ + status: "creating", + fqn, diff --git a/lib/Cloudflare/DNS/Record.d.ts b/lib/Cloudflare/DNS/Record.d.ts index 0479f3dd9e3c81946f3fe93e3a2dc141dde36cad..85c4aee7c16417940a699962bd81378a943082e8 100644 --- a/lib/Cloudflare/DNS/Record.d.ts @@ -575,9 +590,18 @@ index 6e6ed7d006b5ed9d4189e51d7ba0968c04baeff9..c5d2a026b7384500c9a0bf484f95cfef /** * An Action is a node in the dependency graph that runs an Effect with its diff --git a/src/Apply.ts b/src/Apply.ts -index 473a9e7ebe81897693fbd09e70f77e4dd2f7a628..9b593060dfc2a45fedbe5c46c545ac3f74c24684 100644 +index 473a9e7ebe81897693fbd09e70f77e4dd2f7a628..959ba2d21277c6b31d75971c2241dcaa7c7e495e 100644 --- a/src/Apply.ts +++ b/src/Apply.ts +@@ -36,7 +36,7 @@ import { + import type { ApplyStatus } from "./Report.ts"; + import { havePropsChanged, stripUnresolved } from "./Diff.ts"; + import type { Input } from "./Input.ts"; +-import { generateInstanceId, InstanceId } from "./InstanceId.ts"; ++import { generateInstanceId, InstanceId, newInstanceId } from "./InstanceId.ts"; + import * as Output from "./Output.ts"; + import { + type ActionApply, @@ -51,7 +51,7 @@ import { } from "./Provider.ts"; import { stampedMode, type ProviderMode } from "./ProviderMode.ts"; @@ -587,6 +611,15 @@ index 473a9e7ebe81897693fbd09e70f77e4dd2f7a628..9b593060dfc2a45fedbe5c46c545ac3f import { Stage } from "./Stage.ts"; import { type ActionState, +@@ -769,7 +769,7 @@ const executeNode = ( + + const instanceId = yield* Effect.gen(function* () { + if (node.action === "create" && !node.state?.instanceId) { +- const id = yield* generateInstanceId(); ++ const id = yield* newInstanceId(fqn); + yield* commit({ + status: "creating", + fqn, diff --git a/src/Cloudflare/DNS/Record.ts b/src/Cloudflare/DNS/Record.ts index ea4c14420be9d549f937dde03059c329ef2eed42..57979ebbf8afc43351bee0efd66751877be49880 100644 --- a/src/Cloudflare/DNS/Record.ts @@ -2105,3 +2138,128 @@ index 195e301897bdcb118592206f57043045269d61c3..20fc3b04120a131aeb710b390e4d5284 ); const matches = result?.version === STATE_STORE_VERSION; yield* Effect.annotateCurrentSpan({ +diff --git a/lib/InstanceId.js b/lib/InstanceId.js +index 79794dd5816164f84e2a8569501afd64036ce7ae..314dc69439c93af886b8376c9e26060316df1595 100644 +--- a/lib/InstanceId.js ++++ b/lib/InstanceId.js +@@ -1,5 +1,7 @@ + import * as Context from "effect/Context"; + import * as Effect from "effect/Effect"; ++import { StackContext } from "./StackContext.js"; ++import { Stage } from "./Stage.js"; + /** A 16-byte (128-bit) random hex-encoded string representing an physical instance of a logical resource */ + export class InstanceId extends Context.Service()("instance-id") { + } +@@ -13,4 +15,25 @@ export const generateInstanceId = () => Effect.sync(() => { + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }); ++/** ++ * Recovery only. `ALCHEMY_RECOVER_INSTANCE_IDS` maps `//` ++ * to the instance ID of a resource that already exists. ++ */ ++export const newInstanceId = (fqn) => Effect.gen(function* () { ++ const raw = process.env.ALCHEMY_RECOVER_INSTANCE_IDS; ++ if (raw) { ++ const stack = yield* StackContext; ++ const stage = yield* Stage; ++ const key = `${stack.name}/${stage}/${fqn}`; ++ const id = JSON.parse(raw)[key]; ++ if (id !== undefined) { ++ if (!/^[0-9a-f]{32}$/.test(id)) { ++ return yield* Effect.die(new Error(`Recovered instance ID for ${key} is not 32 hex characters`)); ++ } ++ yield* Effect.logInfo(`Recovered instance ID for ${key}`); ++ return id; ++ } ++ } ++ return yield* generateInstanceId(); ++}); + //# sourceMappingURL=InstanceId.js.map +\ No newline at end of file +diff --git a/lib/Plan.js b/lib/Plan.js +index 4a644acc71843c7e66d6cf93a82732ff16f24f43..cf74a0e550d32732bc330f49e0cc7c94b732fd61 100644 +--- a/lib/Plan.js ++++ b/lib/Plan.js +@@ -17,7 +17,7 @@ import { Artifacts, ArtifactStore, createArtifactStore, ensureArtifactStore, mak + import { dedupeBindings, diffBindings, havePropsChanged, isResolved, } from "./Diff.js"; + import { capturedEnvKeys } from "./RuntimeContext.js"; + import { parseFqn } from "./FQN.js"; +-import { generateInstanceId, InstanceId } from "./InstanceId.js"; ++import { InstanceId, newInstanceId } from "./InstanceId.js"; + import * as Output from "./Output.js"; + import { tryFindProviderRegistrationByType, missingProviderError, Provider, providerForMode, tryFindProviderByType, } from "./Provider.js"; + import { defaultProviderMode, stampedMode, } from "./ProviderMode.js"; +@@ -960,7 +960,7 @@ Effect.gen(function* () { + provider.read && + isResolved(news) && + !reusesMigratedFqn) { +- const adoptInstanceId = yield* generateInstanceId(); ++ const adoptInstanceId = yield* newInstanceId(fqn); + const readResult = yield* provider + .read({ + id, +diff --git a/src/InstanceId.ts b/src/InstanceId.ts +index 231a88e748c9c76cbb8bbf2a7ee82dbfd66bb0ed..a1b33ac646c6e8017f91dba838b840c9e8f48747 100644 +--- a/src/InstanceId.ts ++++ b/src/InstanceId.ts +@@ -1,5 +1,7 @@ + import * as Context from "effect/Context"; + import * as Effect from "effect/Effect"; ++import { StackContext } from "./StackContext.ts"; ++import { Stage } from "./Stage.ts"; + + /** A 16-byte (128-bit) random hex-encoded string representing an physical instance of a logical resource */ + export class InstanceId extends Context.Service()( +@@ -17,3 +19,29 @@ export const generateInstanceId = () => + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }); ++ ++/** ++ * Recovery only. `ALCHEMY_RECOVER_INSTANCE_IDS` is a JSON object from ++ * `//` to the instance ID of a resource that already ++ * exists. A deploy whose state was lost uses it instead of a fresh ID, so it ++ * derives the same physical names and adopts the live resources instead of ++ * creating new ones beside them. ++ */ ++export const newInstanceId = (fqn: string) => ++ Effect.gen(function* () { ++ const raw = process.env.ALCHEMY_RECOVER_INSTANCE_IDS; ++ if (raw) { ++ const stack = yield* StackContext; ++ const stage = yield* Stage; ++ const key = `${stack.name}/${stage}/${fqn}`; ++ const id = (JSON.parse(raw) as Record)[key]; ++ if (id !== undefined) { ++ if (!/^[0-9a-f]{32}$/.test(id)) { ++ return yield* Effect.die(new Error(`Recovered instance ID for ${key} is not 32 hex characters`)); ++ } ++ yield* Effect.logInfo(`Recovered instance ID for ${key}`); ++ return id; ++ } ++ } ++ return yield* generateInstanceId(); ++ }); +diff --git a/src/Plan.ts b/src/Plan.ts +index 0123bbf211dfab8c3668f36efb449c5a0dc76e85..5a58f337ab567f6dc148f4eaf82bae96289ccd82 100644 +--- a/src/Plan.ts ++++ b/src/Plan.ts +@@ -39,7 +39,7 @@ import { + } from "./Diff.ts"; + import { capturedEnvKeys } from "./RuntimeContext.ts"; + import { parseFqn } from "./FQN.ts"; +-import { generateInstanceId, InstanceId } from "./InstanceId.ts"; ++import { InstanceId, newInstanceId } from "./InstanceId.ts"; + import * as Output from "./Output.ts"; + import { + tryFindProviderRegistrationByType, +@@ -1510,7 +1510,7 @@ const makePlan =
( + isResolved(news) && + !reusesMigratedFqn + ) { +- const adoptInstanceId = yield* generateInstanceId(); ++ const adoptInstanceId = yield* newInstanceId(fqn); + const readResult = yield* provider + .read({ + id, From 30c9259a3c63af8629419b73ca51ab5eb612db88 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:43:22 +0000 Subject: [PATCH 7/9] Export 1fe74984 --- apps/hosted/cloud/alchemy.posthog.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/hosted/cloud/alchemy.posthog.ts b/apps/hosted/cloud/alchemy.posthog.ts index 676f2b09d3..77dc14d7f1 100644 --- a/apps/hosted/cloud/alchemy.posthog.ts +++ b/apps/hosted/cloud/alchemy.posthog.ts @@ -81,11 +81,12 @@ export default Alchemy.Stack( definition: onboardingExperimentDefinition, status: "running", }).pipe(retain()); - // Independent of the order experiment; also running, so the same deploy launches it. + // Independent of the order experiment. Ended in PostHog on 2026-10-30; an ended experiment + // cannot run again. const onboardingSkip = yield* PostHogExperiment("OnboardingSkipExperiment", { projectId: project.id, definition: onboardingSkipExperimentDefinition, - status: "running", + status: "stopped", }).pipe(retain()); return { onboardingExperimentId: onboarding.id, From 2f4c398f3906e0570f0e25a16fa3602329baba29 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:55:05 +0000 Subject: [PATCH 8/9] Export 7b51faa2 --- ...illed.cloud%2Fcloudflare@1.0.0-rc.13.patch | 35 +++++++++++++++++-- patches/distilled-cloudflare.md | 17 +++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 patches/distilled-cloudflare.md diff --git a/patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch b/patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch index b2da441bc8..609e8c775f 100644 --- a/patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch +++ b/patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch @@ -11,7 +11,7 @@ index 5326a8910d221f48faa2abea203049a240854a97..ed8ce831638e77d3e17753247eb1b9ed headSamplingRate?: number | null; logs?: PutScriptObservabilityLogs | null; diff --git a/lib/services/workers.js b/lib/services/workers.js -index e73a49ba1dfdc68ef0bf3e484d5a1d4045f15cde..d2979e883b4698607a33b89e34143a3860b46dcc 100644 +index e73a49ba1dfdc68ef0bf3e484d5a1d4045f15cde..96819031039645e4d6529a96334bbf867a58476f 100644 --- a/lib/services/workers.js +++ b/lib/services/workers.js @@ -4325,6 +4325,7 @@ export const PutScriptObservabilityTraces = /*@__PURE__*/ S.suspend(() => S.Stru @@ -30,8 +30,23 @@ index e73a49ba1dfdc68ef0bf3e484d5a1d4045f15cde..d2979e883b4698607a33b89e34143a38 queueName: "queue_name", renamedClasses: "renamed_classes", resultInfo: "result_info", +@@ -13354,7 +13356,13 @@ export const listObservabilityDestinations = /*@__PURE__*/ API.makePaginated(() + errors: [Forbidden, CloudflareRateLimited, CloudflareError], + protocol: CloudflarePaginatedProtocol, + retry: Retry.Retry, +- pagination: { mode: "single", items: "result" }, ++ pagination: { ++ mode: "page", ++ inputToken: "page", ++ outputToken: "resultInfo.page", ++ items: "result", ++ pageSize: "perPage", ++ }, + }), cloudflarePaginate); + /** List saved queries. */ + export const listObservabilityQueries = /*@__PURE__*/ API.makePaginated(() => ({ diff --git a/src/services/workers.ts b/src/services/workers.ts -index 476df346840cc07c836fd0ac517264d76d1b654b..b9d3d3ad0d94aca8a50df477da521af1ea9bf491 100644 +index 476df346840cc07c836fd0ac517264d76d1b654b..5dd4648b7d7934c5f8c8cc3c7d83528ba58e61eb 100644 --- a/src/services/workers.ts +++ b/src/services/workers.ts @@ -11280,6 +11280,7 @@ export const PutScriptObservabilityTraces = /*@__PURE__*/ S.suspend(() => @@ -58,3 +73,19 @@ index 476df346840cc07c836fd0ac517264d76d1b654b..b9d3d3ad0d94aca8a50df477da521af1 queueName: "queue_name", renamedClasses: "renamed_classes", resultInfo: "result_info", +@@ -37465,7 +37468,14 @@ export const listObservabilityDestinations: API.PaginatedOperationMethod< + errors: [Forbidden, CloudflareRateLimited, CloudflareError], + protocol: CloudflarePaginatedProtocol, + retry: Retry.Retry, +- pagination: { mode: "single", items: "result" } as const, ++ // The endpoint returns 20 destinations a page and no result_info; read every page. ++ pagination: { ++ mode: "page", ++ inputToken: "page", ++ outputToken: "resultInfo.page", ++ items: "result", ++ pageSize: "perPage", ++ } as const, + }), + cloudflarePaginate, + ) as any; diff --git a/patches/distilled-cloudflare.md b/patches/distilled-cloudflare.md new file mode 100644 index 0000000000..4d54b05e96 --- /dev/null +++ b/patches/distilled-cloudflare.md @@ -0,0 +1,17 @@ +# Distilled Cloudflare patch + +`@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch` changes both the published +JavaScript (`lib/`) and the TypeScript sources (`src/`) of +`services/workers`. It carries: + +- **Upload observability settings.** The script upload codec keeps + `redact_query_string`, which it otherwise dropped + ([request timing](../notes/request-timing.md)). +- **Every observability destination.** `listObservabilityDestinations` reads + every page. The endpoint returns 20 destinations a page and no + `result_info`, and the generated client treated it as a single page, so a + destination past the first 20 could not be found by name. Alchemy's + `ObservabilityDestination` then tried to create it again, and Cloudflare + refused the duplicate name (`ObservabilityDestinationCreateFailed: Bad + Request`). A page past the last is empty, which ends the traversal. Drop this + part when the client paginates the endpoint itself. From 141787b9aad4a1d4e4fab91d6e5fc7ceadee5dfc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:04:28 +0000 Subject: [PATCH 9/9] Export c26a3421 --- apps/hosted/cloud/scripts/edge-contract.ts | 2 +- .../cloud/src/contracts/edge-contract.json | 11 ++-- apps/hosted/cloud/src/contracts/edge-paths.ts | 4 ++ .../src/infrastructure/client-metadata.ts | 63 ++++++++++++------- apps/hosted/cloud/src/main.ts | 14 ++++- apps/hosted/server/package.json | 1 + e2e/test-plan.ts | 16 +++++ e2e/tests/cloud-client-metadata.spec.ts | 58 +++++++++++++++++ 8 files changed, 139 insertions(+), 30 deletions(-) create mode 100644 e2e/tests/cloud-client-metadata.spec.ts diff --git a/apps/hosted/cloud/scripts/edge-contract.ts b/apps/hosted/cloud/scripts/edge-contract.ts index 23a2c7b0e2..0ec2206afb 100644 --- a/apps/hosted/cloud/scripts/edge-contract.ts +++ b/apps/hosted/cloud/scripts/edge-contract.ts @@ -38,6 +38,7 @@ const forwarded: ReadonlyArray = [ ["GET", "/api/auth/callback/google?code=c&state=s"], ["GET", "/api/auth/callback/github"], ["GET", `/api/oauth/callback?code=c&state=${accountOAuthStatePrefix}abc`], + ["GET", "/oauth/client-metadata.json"], ["GET", "/git/acme/site.git/info/refs?service=git-upload-pack"], ["POST", "/git/acme/site.git/git-receive-pack"], ["GET", "/"], @@ -78,7 +79,6 @@ const kept: ReadonlyArray = [ ["GET", "/api/auth/login"], ["GET", "/api/oauth/callback?code=c&state=abc"], ["GET", "/api/oauth/callback?code=c"], - ["GET", "/oauth/client-metadata.json"], ["GET", "/oauth/client-id-metadata.json"], ["GET", "/git"], ["GET", "/gitlab/repo"], diff --git a/apps/hosted/cloud/src/contracts/edge-contract.json b/apps/hosted/cloud/src/contracts/edge-contract.json index 8ad182e0e2..5af17e08ae 100644 --- a/apps/hosted/cloud/src/contracts/edge-contract.json +++ b/apps/hosted/cloud/src/contracts/edge-contract.json @@ -10,6 +10,7 @@ "/.well-known/oauth-authorization-server/api/auth", "/api/auth/callback/", "/api/oauth/callback, when state starts with x2.", + "/oauth/client-metadata.json", "/git/*", "/ (without a v1 session)", "/home", @@ -56,6 +57,11 @@ "target": "/api/oauth/callback?code=c&state=x2.abc", "forwards": true }, + { + "method": "GET", + "target": "/oauth/client-metadata.json", + "forwards": true + }, { "method": "GET", "target": "/git/acme/site.git/info/refs?service=git-upload-pack", @@ -241,11 +247,6 @@ "target": "/api/oauth/callback?code=c", "forwards": false }, - { - "method": "GET", - "target": "/oauth/client-metadata.json", - "forwards": false - }, { "method": "GET", "target": "/oauth/client-id-metadata.json", diff --git a/apps/hosted/cloud/src/contracts/edge-paths.ts b/apps/hosted/cloud/src/contracts/edge-paths.ts index 5ad8c55739..de3bd90f52 100644 --- a/apps/hosted/cloud/src/contracts/edge-paths.ts +++ b/apps/hosted/cloud/src/contracts/edge-paths.ts @@ -12,6 +12,7 @@ */ export const accountOAuthStatePrefix = "x2."; +import { clientMetadataDocumentPath } from "@executor-js/hosted-server/oauth-client-metadata"; import { Option } from "effect"; import { agentSkills, @@ -89,6 +90,9 @@ const routedEdgeForwards: ReadonlyArray = [ url.pathname === "/api/oauth/callback" && (url.searchParams.get("state") ?? "").startsWith(accountOAuthStatePrefix), }, + // The OAuth client metadata document connected-account sign-ins name as their client ID, on the + // edge's origin like their callback. v1's own document is `/oauth/client-id-metadata.json`. + exact(clientMetadataDocumentPath), // Git smart HTTP for app repositories. { rule: "/git/*", matches: (url) => url.pathname.startsWith("/git/") }, // The site: the homepage for visitors without a v1 session, its pages, their files and the diff --git a/apps/hosted/cloud/src/infrastructure/client-metadata.ts b/apps/hosted/cloud/src/infrastructure/client-metadata.ts index 793a3ebc1f..a1a0296c6b 100644 --- a/apps/hosted/cloud/src/infrastructure/client-metadata.ts +++ b/apps/hosted/cloud/src/infrastructure/client-metadata.ts @@ -1,35 +1,38 @@ /** Deployed stages publish their own OAuth Client ID Metadata Document. */ -import { clientMetadataDocumentPath, clientMetadataSetting } from "@executor-js/hosted-server"; +import { + type ClientMetadataSetting, + clientMetadataDocumentPath, + clientMetadataSetting, +} from "@executor-js/hosted-server"; import { Config, Effect, Option } from "effect"; import { cloudAccountRedirectUri } from "../implementation/oauth-proxy-settings.ts"; import { cloudHosts } from "./stage.ts"; /** * The Worker binding that turns the document on for a deployed stage. It is derived from the - * stage's public origin, so it always names the document this Worker serves. Production and - * test stages get it; local development has no public HTTPS origin and leaves it unset. + * stage's public site origin, the edge (`executor.sh`) where the stage has role hosts, so it + * always names the document this Worker serves there. Production and test stages get it; local + * development has no public HTTPS origin and leaves it unset. */ -export const clientMetadataBinding = (origin: URL) => - Effect.gen(function* () { - // A second, hand-set value would leave two bindings for one name. - const configured = yield* Config.String("EXECUTOR_OAUTH_CLIENT_METADATA_URL").pipe( - Config.option, +export const clientMetadataBinding = Effect.gen(function* () { + // A second, hand-set value would leave two bindings for one name. + const configured = yield* Config.String("EXECUTOR_OAUTH_CLIENT_METADATA_URL").pipe(Config.option); + if (Option.isSome(configured)) + return yield* Effect.die( + new Error( + "Deployed stages derive EXECUTOR_OAUTH_CLIENT_METADATA_URL from their origin. " + + "Remove it from the deploy environment.", + ), ); - if (Option.isSome(configured)) - return yield* Effect.die( - new Error( - "Deployed stages derive EXECUTOR_OAUTH_CLIENT_METADATA_URL from their origin. " + - "Remove it from the deploy environment.", - ), - ); - return { - EXECUTOR_OAUTH_CLIENT_METADATA_URL: new URL(clientMetadataDocumentPath, origin).href, - }; - }); + const hosts = yield* cloudHosts; + return { + EXECUTOR_OAUTH_CLIENT_METADATA_URL: new URL(clientMetadataDocumentPath, hosts.site).href, + }; +}); /** - * The document this deployment serves and the callback it lists, from the same settings as the - * account sign-ins: the document handler and the executor both read this one value. + * The document new sign-ins use and the callback it lists, from the same settings as the account + * sign-ins: the edge's document handler and the executor both read this one value. */ export const cloudClientMetadata = Effect.gen(function* () { const hosts = yield* cloudHosts; @@ -39,3 +42,21 @@ export const cloudClientMetadata = Effect.gen(function* () { oauthRedirectUri: Option.getOrUndefined(redirect), }); }); + +/** + * The document the deployment origin serves. Where the current one is on the edge, this is the + * one the deployment origin published before it moved there, unchanged: its URL and its own + * origin's callback. Each grant keeps the client ID it was issued to (`OAuthGrant.client`) and + * renews with it, and an authorization server may fetch that document again, so it stays served. + * No sign-in starts with it any more. Without role hosts the deployment serves the current one. + */ +export const deploymentClientMetadata = (current: Option.Option) => + Effect.gen(function* () { + const { deployment } = yield* cloudHosts; + const url = new URL(clientMetadataDocumentPath, deployment).href; + return Option.map(current, (setting): ClientMetadataSetting => + setting.url === url + ? setting + : { url, served: true, redirectUri: new URL("/api/oauth/callback", deployment).href }, + ); + }); diff --git a/apps/hosted/cloud/src/main.ts b/apps/hosted/cloud/src/main.ts index fb456308a7..261378de9e 100644 --- a/apps/hosted/cloud/src/main.ts +++ b/apps/hosted/cloud/src/main.ts @@ -127,7 +127,11 @@ import { import { roleHostRoutes } from "./infrastructure/role-hosts.ts"; import { redirectTo, withRoleHosts } from "./implementation/host-roles.ts"; import { hasSessionCookie } from "./contracts/browser.ts"; -import { clientMetadataBinding, cloudClientMetadata } from "./infrastructure/client-metadata.ts"; +import { + clientMetadataBinding, + cloudClientMetadata, + deploymentClientMetadata, +} from "./infrastructure/client-metadata.ts"; import { appDataSupervisors } from "./infrastructure/app-data.ts"; import { cloudDevelopment } from "./contracts/development.ts"; import { recordUnclassifiedRefusals, requestServices } from "@executor-js/hosted-server"; @@ -183,7 +187,7 @@ export default Api.make( // Only local e2e Clouds may turn the auth rate limit off; deployed Workers get `false`. ...(yield* authRateLimitSwitchBindings), // A deployed stage identifies Executor to authorization servers by its own document. - ...(origin === undefined ? {} : yield* clientMetadataBinding(origin)), + ...(origin === undefined ? {} : yield* clientMetadataBinding.pipe(Effect.orDie)), // Every deployed stage lists production's official app feed. ...(origin === undefined ? {} : yield* appCatalogBinding), }, @@ -437,6 +441,7 @@ export default Api.make( const egress = yield* cloudEgress; const clientMetadata = yield* cloudClientMetadata.pipe(Effect.orDie); + const deploymentDocument = yield* deploymentClientMetadata(clientMetadata).pipe(Effect.orDie); // The official app feed Add app lists; without it, only the built-in Executor app is listed. const catalogFeedUrl = Option.getOrUndefined( yield* Config.String("EXECUTOR_CATALOG_URL").pipe(Config.option), @@ -657,10 +662,11 @@ export default Api.make( "/api/oauth/callback", cloudAccountCallback(auth.returnsStageAccountCallback), ).pipe(HttpRouter.provideRequest(auth.identity)), + // The document grants issued before it moved to the edge renew with. HttpRouter.add( "GET", clientMetadataDocumentPath, - clientMetadataDocument(clientMetadata), + clientMetadataDocument(deploymentDocument), ).pipe(HttpRouter.provideRequest(auth.identity)), mcpRoutes, authorizationServerRoutes, @@ -717,6 +723,8 @@ export default Api.make( "/api/oauth/callback", cloudAccountCallback(auth.returnsStageAccountCallback), ).pipe(HttpRouter.provideRequest(auth.identity)), + // The OAuth client metadata document sign-ins start with; its callback is the edge's. + HttpRouter.add("GET", clientMetadataDocumentPath, clientMetadataDocument(clientMetadata)), withGitAccess(gitRoutes), // The public site. Nobody is signed in to v2 on the edge, so `/` is always the site's. HttpRouter.add("GET", "/", staticDocument("/index.html")), diff --git a/apps/hosted/server/package.json b/apps/hosted/server/package.json index 1b7b3e1e24..7b416fb63d 100644 --- a/apps/hosted/server/package.json +++ b/apps/hosted/server/package.json @@ -14,6 +14,7 @@ "./groups": "./src/contracts/groups.ts", "./resource-access": "./src/contracts/resource-access.ts", "./oauth-clients": "./src/contracts/oauth-clients.ts", + "./oauth-client-metadata": "./src/contracts/oauth-client-metadata.ts", "./database": "./src/implementation/database.ts", "./resource-lifecycle": "./src/implementation/resource-lifecycle.ts", "./events": "./src/implementation/events.ts", diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 93cebcc660..df0b570b42 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -58,6 +58,11 @@ const httpsCallbackCloud = { status: "scheduled", runtime: "attached" } as const * managed local Cloud reads no feed and lists only the built-in Executor app. */ const officialFeedCloud = { status: "scheduled", runtime: "attached" } as const; +/** + * Reads the OAuth client metadata document a deployed stage publishes: managed local Cloud has no + * public HTTPS origin, so it publishes none. + */ +const publicDocumentCloud = { status: "scheduled", runtime: "attached" } as const; /** Runs alone on a managed local Cloud started with Better Auth's per-address limit on. */ const rateLimitedCloud = { status: "scheduled", runtime: "rate-limited" } as const; const rolledBackCloud = { status: "scheduled", runtime: "rolled-back" } as const; @@ -148,6 +153,17 @@ export const scenarios = plan({ local: na("Local's callback is on its one origin; nothing forwards to it."), }, }, + cloudClientMetadataEdge: { + fixtures: "actors", + file: "cloud-client-metadata.spec.ts", + title: + "Cloud publishes its OAuth client metadata document on the edge with the edge's callback, and the deployment origin keeps the document earlier grants were issued to", + targets: { + cloud: publicDocumentCloud, + "self-host": na("Self-host serves its document on its one origin; nothing forwards to it."), + local: na("Local's loopback callback has no public document; it registers dynamically."), + }, + }, cloudSiteEdge: { file: "cloud-site-edge.spec.ts", title: diff --git a/e2e/tests/cloud-client-metadata.spec.ts b/e2e/tests/cloud-client-metadata.spec.ts new file mode 100644 index 0000000000..da360818a9 --- /dev/null +++ b/e2e/tests/cloud-client-metadata.spec.ts @@ -0,0 +1,58 @@ +/** + * A deployed stage names the OAuth client metadata document on its edge (standing in for + * `executor.sh`) as its client ID, and that document lists the edge's callback, on its own + * origin. The deployment origin (`v2.executor.sh`) and `app.` keep serving the document they + * published before the move, unchanged: grants issued to it renew with its URL as their client ID, + * and an authorization server may fetch it again. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; +import { rawRequest, targetHosts } from "../support/role-hosts.ts"; +import { scenarios } from "../test-plan.ts"; + +const Document = Schema.fromJsonString( + Schema.Struct({ + client_id: Schema.String, + client_uri: Schema.String, + logo_uri: Schema.String, + redirect_uris: Schema.Array(Schema.String), + token_endpoint_auth_method: Schema.String, + application_type: Schema.String, + }), +); +const path = "/oauth/client-metadata.json"; + +/** The document at `origin`, which must name itself and list `origin`'s callback. */ +const documentAt = (at: string, origin: string) => + Effect.gen(function* () { + const response = yield* rawRequest(`${at}${path}`); + expect(response.status, `${at}${path}`).toBe(200); + expect(response.contentType, `${at}${path}`).toContain("application/json"); + expect(yield* Schema.decodeUnknownEffect(Document)(response.text), `${at}${path}`).toEqual({ + client_id: `${origin}${path}`, + client_uri: origin, + logo_uri: `${origin}/favicon.png`, + redirect_uris: [`${origin}/api/oauth/callback`], + token_endpoint_auth_method: "none", + application_type: "web", + }); + }); + +layer(HostedLive, { excludeTestServices: true })("Cloud client metadata document", (it) => { + it.effect(scenarios.cloudClientMetadataEdge.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const hosts = targetHosts(yield* Target); + expect(hosts.edge).not.toBe(hosts.deployment); + // The document sign-ins start with: on the edge, listing the edge's callback. + yield* documentAt(hosts.edge, hosts.edge); + // The document earlier grants were issued to, on the deployment origin and `app.`. + yield* documentAt(hosts.deployment, hosts.deployment); + yield* documentAt(hosts.browser, hosts.deployment); + }), + ), + ); +});