Unified · Split
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The CLI's server-connection store is written owner-only, and two of its tests now actually run** | ||
| 6 | |||
| 7 | `~/.executor/server-connections.json` holds live credentials for a hosted server — a bearer token, or an OAuth access token together with its long-lived refresh token, rewritten on every silent refresh. It was created with no explicit mode, so the process umask applied and it landed world-readable (0644 by default). Any other account on the machine — or anything that copies a home directory, such as a backup or a container layer — could read a durable credential until the user ran `executor logout`. | ||
| 8 | |||
| 9 | It is now created `0600` with a follow-up `chmod`, matching what the local-server manifest already does for the sibling secret it keeps under `server-control/`. Both steps are needed: `mode` applies only on create, and the `chmod` covers rewriting a store that already exists with looser permissions — which is the common path here, since the file is rewritten on every token refresh. | ||
| 10 | |||
| 11 | Separately, two tests in `server-profile.test.ts` were written as `it("…", () => Effect.gen(…))`. An `Effect` is not a thenable, so Vitest treated each as passing without ever running its body — a deliberately falsified assertion still passed. They are now `it.effect` and execute for real. No production behaviour was wrong; the tests simply were not checking it. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first. | ||
| @@ -18,7 +18,8 @@ · 17 unmodified lines | |||
| 18 | "@executor-js/plugin-openapi", | 18 | "@executor-js/plugin-openapi", |
| 19 | "@executor-js/plugin-example", | 19 | "@executor-js/plugin-example", |
| 20 | "@executor-js/plugin-desktop-settings", | 20 | "@executor-js/plugin-desktop-settings", |
| 21 | "@executor-js/desktop" | 21 | "@executor-js/desktop", |
| 22 | "@executor-js/local" | ||
| 22 | ] | 23 | ] |
| 23 | ], | 24 | ], |
| 24 | "linked": [], | 25 | "linked": [], |
| @@ -29,7 +30,6 @@ · 4 unmodified lines | |||
| 29 | "onlyUpdatePeerDependentsWhenOutOfRange": true | 30 | "onlyUpdatePeerDependentsWhenOutOfRange": true |
| 30 | }, | 31 | }, |
| 31 | "ignore": [ | 32 | "ignore": [ |
| 32 | "@executor-js/local", | ||
| 33 | "@executor-js/host-mcp", | 33 | "@executor-js/host-mcp", |
| 34 | "@executor-js/ir", | 34 | "@executor-js/ir", |
| 35 | "@executor-js/runtime-deno-subprocess", | 35 | "@executor-js/runtime-deno-subprocess", |
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/react": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The connection edit sheet now previews what agents actually read** | ||
| 6 | |||
| 7 | The "What agents see" preview in the connection edit sheet rendered a `- \`<prefix>\` — <description>`inventory line. That line left the`execute`tool description when the inventory was slimmed to bare integration slugs, so the preview showed text no agent reads. The account label was also marked "Display-only", but`connections.list` returns it to agents alongside the description. | ||
| 8 | |||
| 9 | The preview now mirrors the `connections.list` item for the connection (`name`, `identityLabel`, `description`), and the sheet copy says that both fields are agent-visible while the callable name stays as it was at connect time. | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The desktop settings store is written owner-only** | ||
| 6 | |||
| 7 | `settings.json` holds `serverProfiles`, which carries a remote server's credential — a bearer token, or a basic-auth username and password — for any "Custom server" the user connects to. `conf` (under electron-store) defaults to `configFileMode: 0o666`, so with no explicit mode the file was created `0644`. On Linux, where `~/.config` is not reliably `0700`, that is readable by every other account on the machine. macOS is protected by `~/Library` being `0700` and Windows by ACLs, so this is primarily a Linux-desktop exposure — but owner-only credential files are already this app's standard: `local-auth.ts` writes `auth.json` at `0o600`, and the sidecar manifest is chmodded the same way. | ||
| 8 | |||
| 9 | One option is sufficient here, rather than the mode-plus-chmod pair used elsewhere: `atomically` chmods the temp inode only when the requested mode differs from its own default, and the atomic rename then carries the tight mode onto an already-loose file. Verified against the installed `conf` — with no option a fresh file lands `0644`; with `configFileMode: 0o600` a fresh file lands `0600` and an existing `0644` file becomes `0600` on the next write. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-openapi": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Make Microsoft Graph slice URLs first-class spec sources instead of a hidden substitution. Catalog tiles now point directly at the slice release assets, the stored specUrl is exactly what gets fetched, and selection narrowing travels visibly in the URL fragment; requesting the upstream monolith URL fetches the monolith, never a silently swapped slice. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-openapi": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Serve Microsoft Graph preset selections from precomputed slice release assets instead of the 43MB upstream monolith. The monolith fetch almost never survives a 128MB Workers isolate (production traces show one completion in 30 days), so covered selections — every catalog preset, plus any combination within the default bundle — now read a 4–19MB filtered document built offline by the graph-slices workflow, with the monolith path kept only as a fallback and for full-graph/custom-scope selections. | ||
| @@ -1,5 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Back-stop code execution with a host-side timeout so a wedged sandbox delivers a descriptive error instead of hanging silently. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Load the MCP client SDK lazily on first outbound connection instead of at module evaluation. Runtimes that bundle the plugin (notably Cloudflare Workers) no longer pay the client package's module-eval memory and CPU on startup or on code paths that never dial an MCP server. | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Closing a remote MCP connection now ends its streamable-http SSE request** | ||
| 6 | |||
| 7 | On a supplied `httpClientLayer`, the fetch adapter wired the caller's `AbortSignal` only to the pending response promise, never to the response body, so closing a connection left the long-lived `GET` channel in flight forever — one abandoned request per dial. Under Bun each holds one of the 256 concurrent-request slots, so a long-running process eventually exhausts the pool and every connection starts failing with `MCP discovery timed out after 15000ms`. The response stream is now interrupted when the signal aborts. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **An MCP health check no longer reports `healthy` when the connection's credential is missing** | ||
| 6 | |||
| 7 | Rendering skips an auth placement whose value is unresolved — that is the renderer's documented behaviour, and callers own the missing-value policy. The MCP health check had no such policy, so it dialled unauthenticated, and any server that lists tools without auth answered. `discoverTools` succeeding maps straight to `healthy`, so a connection whose credential was gone reported as healthy. | ||
| 8 | |||
| 9 | Health status is the signal telling a user to re-authenticate, which makes `healthy` the one answer it must never give in that state. The check now reports `expired` with the unresolved input names, mirroring the OpenAPI health check, which already did exactly this. | ||
| 10 | |||
| 11 | The MCP tool-invocation path already refused for the same reason. `resolveTools` is deliberately left alone — its own comment records that discovery tolerating unresolved credentials is intended, since an open server lists tools unauthenticated. | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/execution": patch | ||
| 3 | "executor": patch | ||
| 4 | --- | ||
| 5 | |||
| 6 | **Opt-in per-integration search tools on the MCP surface** | ||
| 7 | |||
| 8 | Connecting with `?search_tools=true` (stdio: `executor mcp --search-tools`) adds one minimally-described `search_<integration>` MCP tool per connected integration, so the integration namespaces reach the model as tool names it can see without calling anything. Each call routes through the same flow as `tools.search({ namespace })` inside `execute`, and the tool list comes from the same inventory the `execute` description shows. Off by default; a clean endpoint URL is unchanged. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The MCP connection pool no longer keeps credentials in its cache key** | ||
| 6 | |||
| 7 | A pooled remote MCP session is looked up by a key describing the connection's identity, and that key included the connection's resolved credential values — plus the headers and query params those same secrets had already been rendered into. The key is retained as a `Map` key for the pool's lifetime, so the secret stayed readable in process memory long after the call that needed it had finished, with nothing left to read it. | ||
| 8 | |||
| 9 | The key is now the SHA-256 digest of that identity rather than the identity itself. Reuse is unchanged, because equal identities still produce equal keys, and separation is unchanged too: a rotated access token, a different rendered auth header and a credential carried in a query param each still dial a fresh session instead of reusing one authenticated as somebody else. Hashing the whole identity rather than only the fields known to be sensitive means a field added later is covered without anyone having to remember it carries a secret. | ||
| 10 | |||
| 11 | Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes. | ||
| @@ -1,5 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call. | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Idle MCP session runtimes are actually reclaimed** | ||
| 6 | |||
| 7 | The MCP session Durable Object has an idle timeout that disposes a session's execution runtime — the execution engine and its executor closure, the built tool catalog, and a live database handle — once the session has gone quiet. That timeout never ran. | ||
| 8 | |||
| 9 | The session arms an idle alarm on every request. The agents framework independently recomputes the Durable Object alarm from its own schedule table and keep-alive refcount, and when it finds neither it does not leave the alarm alone: it deletes it. It releases the last keep-alive reference at the end of every ordinary tool call, from a `waitUntil` that runs just after the response goes out — so the idle alarm the session had armed moments earlier was erased, and a session that had just served a request was left with no alarm at all. Its runtime then stayed resident until the platform evicted the whole object. | ||
| 10 | |||
| 11 | Durable Objects are colocated many-to-one onto an isolate with a single heap, so runtimes that are never reclaimed accumulate there. When the heap is exhausted the allocation that fails is whichever comes next, anywhere in the isolate — which is why the failure tended to surface from storage rather than from the runtimes that had consumed the memory. | ||
| 12 | |||
| 13 | The idle deadline belongs to the session, not to the framework's scheduler, so it is now re-asserted after the framework has arranged whatever it needs — and only while a runtime is actually resident, since once there is nothing left to reclaim the framework's answer is correct. | ||
| 14 | |||
| 15 | Disposal also now emits a span carrying a per-isolate resident-runtime gauge, alongside the same gauge on runtime build, so the reclaim can be confirmed in production rather than inferred. | ||
| @@ -1,5 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Preserve MCP tool results across dropped streamable HTTP SSE connections. | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Fix: declare the OAuth application type during dynamic client registration** | ||
| 6 | |||
| 7 | Dynamic OAuth registrations now identify HTTPS callbacks as web applications | ||
| 8 | and loopback HTTP callbacks as native applications. This lets strict OAuth | ||
| 9 | servers validate Executor's redirect URI against the correct client type. | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The OAuth popup clears its result out of `localStorage` after handing it over** | ||
| 6 | |||
| 7 | The popup writes its result to `localStorage` as the fallback completion channel, because `postMessage` is severed when a provider's consent page sets COOP and `BroadcastChannel` can be partitioned or raced by the auto-close. Nothing removed that entry afterwards, so the payload — which carries the identity label, an email, and on failure the error preview — stayed parked in the user's browser profile. | ||
| 8 | |||
| 9 | The entry is now cleared once the handover has had time to land, and on `pagehide` as a backstop — the failure page never auto-closes so the user can read the error, and closing it by hand would otherwise cancel the pending timer and strand the entry. This cannot cost a listener the result: a `storage` event captures `newValue` at dispatch, so an opener that has been notified already holds it. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The 1Password service-account token is cleared from the op-js global after each call** | ||
| 6 | |||
| 7 | `@1password/op-js` keeps the service-account token on a module-level CLI instance (`cli.serviceAccountToken`) and reads it when it spawns `op`. The CLI backend set that global before each call and never cleared it, so one reachable reference to the token stayed live for the rest of the process. | ||
| 8 | |||
| 9 | It is now cleared as soon as the call that needed it is done, on success, failure and interruption alike. Authentication is unaffected: every read and write of that global already happens inside the backend's semaphore, so the next operation re-sets the token before it spawns anything. | ||
| 10 | |||
| 11 | This is hygiene rather than a boundary change. No unrelated `op` child ever received a stale token — every call routes through the same critical section that sets the correct one immediately before invoking — and the token is separately persisted in plaintext in the plugin's config blob, so an attacker's reach is unchanged. What it removes is a long-lived reachable reference that nothing needed to keep. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-openapi": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Preview OpenAPI spec-format selections (Microsoft Graph) through the streaming structural-split path instead of a whole-document parse, and guard generic whole-document parses by parsed-tree size (line count for block YAML, text size for JSON). Previewing a Graph preset URL previously parsed the 43MB source whole and killed the 128MB Workers isolate mid-request, surfacing as an empty 503; it now streams within budget, and oversized generic specs fail with an actionable error instead of taking down the isolate. | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/execution": patch | ||
| 3 | "executor": patch | ||
| 4 | --- | ||
| 5 | |||
| 6 | Slim the per-integration `search_<integration>` tool definitions to under half their size: one shared one-line description (the tool name already carries the namespace) and a single bare `query` parameter, dropping the `limit`/`offset` knobs. A session pays for these definitions once per connected integration, so the surface now costs ~2k tokens instead of ~5k at 30 integrations; paging through a namespace belongs in `execute`. | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/sdk": patch | ||
| 3 | "@executor-js/api": patch | ||
| 4 | --- | ||
| 5 | |||
| 6 | Build `StorageError.message` from the call-site label plus the driver's error code instead of the driver's raw text. The driver text is drizzle's `Failed query: <sql>\nparams: <bound values>`, so error reporting grouped one storage defect by statement shape and printed bound parameters into issue titles. The full driver error stays on `cause`. | ||
| 7 | |||
| 8 | Add `StorageConnectionError`, a `StorageFailure` variant for postgres.js connection faults (`CONNECTION_ENDED`, `CONNECTION_CLOSED`, `CONNECTION_DESTROYED`, `CONNECT_TIMEOUT`, `ECONNREFUSED`, `ECONNRESET`) and workerd's cross-request I/O rejection. It carries the fault `code` and a `retryable` flag so a lost socket can be told apart from a pool-lifetime bug. | ||
| @@ -1,5 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Throw a guidance error when sandbox code enumerates the `tools` proxy (`Object.keys`, spread, `for...in`) instead of returning an empty list, pointing agents at `tools.search()`. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The packed Worker toolchain is verified at build time, and an incomplete copy is now reported instead of silently ignored** | ||
| 6 | |||
| 7 | `@cloudflare/worker-bundler` cannot live inside the compiled binary: bunfs has no `node_modules`, so a bare specifier is unresolvable there by construction. The build instead copies the package's `dist/` next to the executable and `native-bindings.ts` publishes that path as `EXECUTOR_WORKER_BUNDLER_DIR` for consumers to load from. | ||
| 8 | |||
| 9 | That handoff was described in two places that were free to drift, and did. The build writes `dist/index.bundled.js` — the entry consumers actually load, packed so it has no bare imports of its own — while the runtime check only looked for `dist/index.js` and `dist/esbuild.wasm`. Nothing verified the staged copy after the compile, so a partial staging produced a binary that looked fine on the build machine and failed on the user's, at startup. Worse, the runtime check failed open: when a file was missing it silently declined to set the environment variable, leaving a consumer to fall through to the bare specifier and crash. | ||
| 10 | |||
| 11 | The required file list is now one shared contract used by both sides, so they cannot disagree. The build asserts the staged copy after compiling each target — every required file present, a size floor on the packed entry, and the `\0asm` magic on the wasm so a truncated copy cannot pass — turning a packaging slip into a failed build rather than a broken install. At runtime, a directory that is present but incomplete is reported on stderr naming the missing files, instead of being swallowed. An absent directory stays quiet, since that is the normal non-packaged path. | ||
| @@ -1,5 +0,0 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Treat a transient WorkOS outage during the MCP live-membership check as a retryable 503 instead of a Forbidden that destroys the session. | ||
| @@ -32,18 +32,22 @@ const github = await createEmulator({ service: "github", port: 4501 }); · 31 unmodified lines | |||
| 32 | `servers`) when a proxy fronts the emulator — the bind stays on `port`. | 32 | `servers`) when a proxy fronts the emulator — the bind stays on `port`. |
| 33 | 33 | ||
| 34 | **Attach to a running one** (another process, or hosted on Cloudflare with | 34 | **Attach to a running one** (another process, or hosted on Cloudflare with |
| 35 | Durable Object state at `https://<service>.emulators.dev` / | 35 | Durable Object state at `https://<service>.<instance>.emulators.dev` — |
| 36 | `https://<service>.<instance>.emulators.dev` — catalog at | 36 | catalog at `GET https://emulators.dev/_emulate/services`). Service hosts |
| 37 | `GET https://emulators.dev/_emulate/services`): | 37 | (`https://<service>.emulators.dev`) are control plane only, with no shared |
| 38 | default instance behind them: create a private instance first and connect to | ||
| 39 | the returned `providerBaseUrl`. | ||
| 38 | 40 | ||
| 39 | ```ts | 41 | ```ts |
| 40 | import { connectEmulator } from "@executor-js/emulate"; | 42 | import { connectEmulator } from "@executor-js/emulate"; |
| 41 | const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" }); | 43 | const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" }); |
| 44 | const { providerBaseUrl } = await created.json(); | ||
| 45 | const resend = await connectEmulator({ baseUrl: providerBaseUrl }); | ||
| 42 | // optional: { service: "resend" } verifies the manifest on connect | 46 | // optional: { service: "resend" } verifies the manifest on connect |
| 43 | ``` | 47 | ``` |
| 44 | 48 | ||
| 45 | Create a private hosted instance with `POST /_emulate/instances` on the | 49 | In e2e scenarios use `createEmulatorInstance` from |
| 46 | service host. | 50 | `e2e/src/emulator-instance.ts`, which wraps this. |
| 47 | 51 | ||
| 48 | ## The typed control-plane client | 52 | ## The typed control-plane client |
| 49 | 53 | ||
| @@ -113,12 +117,11 @@ The emulate repo's own `AGENTS.md` / `README.md` carry the current build, · 63 unmodified lines | |||
| 113 | publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read | 117 | publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read |
| 114 | them there rather than memorizing flags here — they move. | 118 | them there rather than memorizing flags here — they move. |
| 115 | 119 | ||
| 116 | **A hot deploy can redden other people's e2e.** `*.emulators.dev` service | 120 | **A hot deploy can redden other people's e2e.** The `emulate-hosts` worker |
| 117 | hosts are shared infrastructure; a control-plane regression there has failed | 121 | behind `*.emulators.dev` is shared infrastructure; a control-plane regression |
| 118 | unrelated PRs' suites before. When a scenario needs isolation or a behavior | 122 | there has failed unrelated PRs' suites before. Scenarios always run on private |
| 119 | that isn't deployed yet, pin to a published package version or mint a private | 123 | per-run instances (`POST /_emulate/instances`); when a scenario needs behavior |
| 120 | per-run instance (`POST /_emulate/instances`) instead of mutating the shared | 124 | that isn't deployed yet, pin to a published package version. |
| 121 | service host. | ||
| 122 | 125 | ||
| 123 | ## Gotchas | 126 | ## Gotchas |
| 124 | 127 | ||
| @@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines | |||
| 29 | 29 | ||
| 30 | **Span names worth querying** (and their custom attrs): | 30 | **Span names worth querying** (and their custom attrs): |
| 31 | 31 | ||
| 32 | - `mcp.execute` / `mcp.execute.resume` — `mcp.execute.mode` | ||
| 33 | (`pausable`/`inline`), `mcp.execute.code_length`, and | ||
| 34 | `mcp.execute.outcome` (`ok`/`fail`/`paused`) with, on failures, | ||
| 35 | `mcp.execute.error_kind` (`type_error` | `reference_error` | | ||
| 36 | `syntax_error` | `range_error` | `tool_error` | `timeout` | | ||
| 37 | `resource_limit` | `serialization_error` | `thrown` | `unknown`). | ||
| 38 | Sandbox script failures ride the MCP success channel, so `status.code` | ||
| 39 | stays OK — filter on these attributes, not span status. Spans from | ||
| 40 | before the attributes shipped carry neither; absence is not success. | ||
| 41 | Also `mcp.execute.result_chars` (compact-JSON size of the returned | ||
| 42 | value, pre-truncation; -1 = unmeasurable), `mcp.execute.log_chars`, | ||
| 43 | `mcp.execute.emitted` — the dump-vs-narrow signal (the model preview | ||
| 44 | truncates at 30k chars, so `result_chars > 30000` means the model tried | ||
| 45 | to pull a truncated blob into context). | ||
| 32 | - `executor.tool.execute` — `mcp.tool.name` (full address), and since | 46 | - `executor.tool.execute` — `mcp.tool.name` (full address), and since |
| 33 | PR #992: `executor.tool.outcome` (`ok`/`fail`), | 47 | PR #992: `executor.tool.outcome` (`ok`/`fail`), |
| 34 | `executor.tool.error_code`, `executor.tool.error_status`, | 48 | `executor.tool.error_code`, `executor.tool.error_status`, |
| @@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines | |||
| 39 | `base_url`, and since PR #992 `http.status_code`. | 53 | `base_url`, and since PR #992 `http.status_code`. |
| 40 | - `mcp.request` (outer) — `mcp.auth.organization_id`, | 54 | - `mcp.request` (outer) — `mcp.auth.organization_id`, |
| 41 | `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), | 55 | `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), |
| 42 | MCP client fingerprint (`mcp.client.name`…). | 56 | MCP client fingerprint (`mcp.client.name`…), and on managed-cloud |
| 57 | `execute`/`execute-action` calls `mcp.execute.code` (the script itself, | ||
| 58 | capped at 10k chars — cloud-only content capture; local/self-host | ||
| 59 | telemetry never records content). | ||
| 43 | 60 | ||
| 44 | **Recipe — error signatures by class (the daily-digest query):** | 61 | **Recipe — error signatures by class (the daily-digest query):** |
| 45 | 62 | ||
| @@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines | |||
| 10 | group: ci-${{ github.ref }} | 10 | group: ci-${{ github.ref }} |
| 11 | cancel-in-progress: true | 11 | cancel-in-progress: true |
| 12 | 12 | ||
| 13 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 14 | # production PostHog project from every e2e shard: the analytics layer | ||
| 15 | # (integration_added, execution_completed, artifact_*) and the | ||
| 16 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 17 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 18 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 19 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 20 | env: | ||
| 21 | DO_NOT_TRACK: "1" | ||
| 22 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 23 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 24 | |||
| 13 | jobs: | 25 | jobs: |
| 14 | changes: | 26 | changes: |
| 15 | name: Changed paths | 27 | name: Changed paths |
| @@ -18,6 +30,7 @@ jobs: · 2 unmodified lines | |||
| 18 | outputs: | 30 | outputs: |
| 19 | desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} | 31 | desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} |
| 20 | selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }} | 32 | selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }} |
| 33 | cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }} | ||
| 21 | steps: | 34 | steps: |
| 22 | - uses: actions/checkout@v4 | 35 | - uses: actions/checkout@v4 |
| 23 | 36 | ||
| @@ -41,6 +54,14 @@ jobs: · 17 unmodified lines | |||
| 41 | - "packages/kernel/runtime-quickjs/**" | 54 | - "packages/kernel/runtime-quickjs/**" |
| 42 | - "packages/plugins/**" | 55 | - "packages/plugins/**" |
| 43 | - "packages/react/**" | 56 | - "packages/react/**" |
| 57 | cloud_closure: | ||
| 58 | - ".github/workflows/**" | ||
| 59 | - "bun.lock" | ||
| 60 | - "package.json" | ||
| 61 | - "turbo.json" | ||
| 62 | - "apps/cloud/**" | ||
| 63 | - "packages/**" | ||
| 64 | |||
| 44 | selfhost_docker_smoke: | 65 | selfhost_docker_smoke: |
| 45 | - ".github/workflows/**" | 66 | - ".github/workflows/**" |
| 46 | - ".dockerignore" | 67 | - ".dockerignore" |
| @@ -136,17 +157,26 @@ jobs: · 89 unmodified lines | |||
| 136 | 157 | ||
| 137 | - run: bun run typecheck | 158 | - run: bun run typecheck |
| 138 | 159 | ||
| 160 | # The committed routeTree.gen.ts files are generated but checked in, and | ||
| 161 | # they carry @ts-nocheck — so the typecheck above structurally CANNOT see | ||
| 162 | # a route that the contract declares and the tree lacks. That drift has | ||
| 163 | # already shipped a live 404 once. Regenerate and diff instead. | ||
| 164 | - run: bun run check:routes | ||
| 165 | |||
| 139 | test: | 166 | test: |
| 140 | name: Test | 167 | name: Test |
| 141 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 168 | runs-on: blacksmith-16vcpu-ubuntu-2404 |
| 142 | timeout-minutes: 15 | 169 | timeout-minutes: 15 |
| 143 | # Tuned for Blacksmith's 4 vCPU runners. | 170 | # Run eight independent packages at once and cap each Vitest process at two |
| 171 | # workers. This uses all 16 vCPUs without every nested runner seeing the | ||
| 172 | # whole machine and oversubscribing it. | ||
| 144 | env: | 173 | env: |
| 145 | TURBO_API: ${{ vars.TURBO_API }} | 174 | TURBO_API: ${{ vars.TURBO_API }} |
| 146 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} | 175 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} |
| 147 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | 176 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} |
| 148 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} | 177 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} |
| 149 | TURBO_TEST_CONCURRENCY: 4 | 178 | TURBO_TEST_CONCURRENCY: 8 |
| 179 | VITEST_MAX_WORKERS: 2 | ||
| 150 | steps: | 180 | steps: |
| 151 | - uses: actions/checkout@v4 | 181 | - uses: actions/checkout@v4 |
| 152 | 182 | ||
| @@ -178,6 +208,8 @@ jobs: · 25 unmodified lines | |||
| 178 | # no signal. This is just a few file reads plus greps, so ~zero cost. | 208 | # no signal. This is just a few file reads plus greps, so ~zero cost. |
| 179 | - run: bun run check:patches | 209 | - run: bun run check:patches |
| 180 | 210 | ||
| 211 | - run: bun run --cwd e2e test:ci-shard | ||
| 212 | |||
| 181 | - run: bun run test | 213 | - run: bun run test |
| 182 | 214 | ||
| 183 | e2e: | 215 | e2e: |
| @@ -186,14 +218,41 @@ jobs: · 2 unmodified lines | |||
| 186 | fail-fast: false | 218 | fail-fast: false |
| 187 | matrix: | 219 | matrix: |
| 188 | include: | 220 | include: |
| 189 | # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, | 221 | # The planner assigns every file exactly once using recorded slow-file |
| 190 | # four fatter shards keep the longest shard below selfhost while saving | 222 | # durations plus a conservative weight for new tests. The cloud DB's |
| 191 | # four runner boots and four warm cache restores. | 223 | # connection teardown and concurrent socket protocol have dedicated |
| 192 | - { target: cloud, shard: 1/4, shard-name: 1of4 } | 224 | # regression tests; these shards balance wall clock, not hide retries. |
| 193 | - { target: cloud, shard: 2/4, shard-name: 2of4 } | 225 | - { target: cloud, shard-index: 1, shard-name: 1of16 } |
| 194 | - { target: cloud, shard: 3/4, shard-name: 3of4 } | 226 | - { target: cloud, shard-index: 2, shard-name: 2of16 } |
| 195 | - { target: cloud, shard: 4/4, shard-name: 4of4 } | 227 | - { target: cloud, shard-index: 3, shard-name: 3of16 } |
| 196 | - target: selfhost | 228 | - { target: cloud, shard-index: 4, shard-name: 4of16 } |
| 229 | - { target: cloud, shard-index: 5, shard-name: 5of16 } | ||
| 230 | - { target: cloud, shard-index: 6, shard-name: 6of16 } | ||
| 231 | - { target: cloud, shard-index: 7, shard-name: 7of16 } | ||
| 232 | - { target: cloud, shard-index: 8, shard-name: 8of16 } | ||
| 233 | - { target: cloud, shard-index: 9, shard-name: 9of16 } | ||
| 234 | - { target: cloud, shard-index: 10, shard-name: 10of16 } | ||
| 235 | - { target: cloud, shard-index: 11, shard-name: 11of16 } | ||
| 236 | - { target: cloud, shard-index: 12, shard-name: 12of16 } | ||
| 237 | - { target: cloud, shard-index: 13, shard-name: 13of16 } | ||
| 238 | - { target: cloud, shard-index: 14, shard-name: 14of16 } | ||
| 239 | - { target: cloud, shard-index: 15, shard-name: 15of16 } | ||
| 240 | - { target: cloud, shard-index: 16, shard-name: 16of16 } | ||
| 241 | - { target: selfhost, shard-index: 1, shard-name: 1of10 } | ||
| 242 | - { target: selfhost, shard-index: 2, shard-name: 2of10 } | ||
| 243 | - { target: selfhost, shard-index: 3, shard-name: 3of10 } | ||
| 244 | - { target: selfhost, shard-index: 4, shard-name: 4of10 } | ||
| 245 | - { target: selfhost, shard-index: 5, shard-name: 5of10 } | ||
| 246 | - { target: selfhost, shard-index: 6, shard-name: 6of10 } | ||
| 247 | - { target: selfhost, shard-index: 7, shard-name: 7of10 } | ||
| 248 | - { target: selfhost, shard-index: 8, shard-name: 8of10 } | ||
| 249 | - { target: selfhost, shard-index: 9, shard-name: 9of10 } | ||
| 250 | - { target: selfhost, shard-index: 10, shard-name: 10of10 } | ||
| 251 | # Local files own their server, browser and data directory. Separate | ||
| 252 | # runners preserve that isolation while removing its 69-second serial | ||
| 253 | # lane from the two-minute critical path. | ||
| 254 | - { target: local, shard-index: 1, shard-name: 1of2 } | ||
| 255 | - { target: local, shard-index: 2, shard-name: 2of2 } | ||
| 197 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 256 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 198 | timeout-minutes: 30 | 257 | timeout-minutes: 30 |
| 199 | steps: | 258 | steps: |
| @@ -229,26 +288,31 @@ jobs: · 29 unmodified lines | |||
| 229 | 288 | ||
| 230 | # Install from e2e so bunx resolves ITS pinned playwright (the version | 289 | # Install from e2e so bunx resolves ITS pinned playwright (the version |
| 231 | # the tests run against) rather than floating to the latest. | 290 | # the tests run against) rather than floating to the latest. |
| 291 | # Blacksmith's Ubuntu image carries the official GitHub runner system | ||
| 292 | # dependencies. Restore the pinned browser binaries without apt-updating | ||
| 293 | # every matrix machine. | ||
| 232 | - name: Install Playwright Chromium | 294 | - name: Install Playwright Chromium |
| 233 | run: bunx playwright install --with-deps chromium chromium-headless-shell | 295 | run: bunx playwright install chromium chromium-headless-shell |
| 234 | working-directory: e2e | 296 | working-directory: e2e |
| 235 | 297 | ||
| 236 | # The globalsetup boots the target's own dev server (ports are claimed | 298 | # Each target either boots its own shared dev server or lets each file own |
| 237 | # per checkout, so this is hermetic) and tears it down after the run. | 299 | # its server. Ports and data paths are hermetic in both cases. |
| 238 | # --retry=2: browser scenarios can still hit isolated waitFor timeouts | 300 | # Do not retry scenarios: retries hide flakes and multiply slow timeout |
| 239 | # (single-test waitFor timeouts, not systemic failures); a retry on the | 301 | # failures. The fixtures and process lifecycle are deterministic enough |
| 240 | # same booted stack clears them. | 302 | # that the first result is the result. |
| 241 | - name: Run cloud scenarios | 303 | - name: Run cloud shard |
| 242 | if: matrix.target == 'cloud' | 304 | if: matrix.target == 'cloud' |
| 243 | env: | 305 | env: |
| 244 | MCP_SESSION_TIMEOUT_MS: "3000" | 306 | MCP_SESSION_TIMEOUT_MS: "3000" |
| 245 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" | 307 | # Still 18x shorter than production, but long enough for a cold Vite |
| 246 | run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | 308 | # resume route to compile under a fully loaded CI runner. |
| 309 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "30000" | ||
| 310 | run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }} | ||
| 247 | working-directory: e2e | 311 | working-directory: e2e |
| 248 | 312 | ||
| 249 | - name: Run selfhost scenarios | 313 | - name: Run scenarios |
| 250 | if: matrix.target == 'selfhost' | 314 | if: matrix.target != 'cloud' |
| 251 | run: bunx vitest run --project selfhost --retry=2 | 315 | run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }} |
| 252 | working-directory: e2e | 316 | working-directory: e2e |
| 253 | 317 | ||
| 254 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in | 318 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in |
| @@ -261,13 +325,23 @@ jobs: · 6 unmodified lines | |||
| 261 | path: e2e/runs/ | 325 | path: e2e/runs/ |
| 262 | retention-days: 7 | 326 | retention-days: 7 |
| 263 | 327 | ||
| 264 | e2e-local: | 328 | cloud-closure: |
| 265 | name: E2E (stdio MCP) | 329 | name: Cloud evaluated closure |
| 266 | # Skipped on pull_request: the local scenario boots a real `executor web` | 330 | needs: changes |
| 267 | # plus a browser and is currently flaky on PRs. Still runs on push to main. | 331 | if: needs.changes.outputs.cloud_closure == 'true' |
| 268 | if: github.event_name != 'pull_request' | ||
| 269 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 332 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 270 | timeout-minutes: 20 | 333 | timeout-minutes: 20 |
| 334 | env: | ||
| 335 | TURBO_API: ${{ vars.TURBO_API }} | ||
| 336 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} | ||
| 337 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | ||
| 338 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} | ||
| 339 | # Every cold isolate evaluates this closure before it can answer a | ||
| 340 | # request, and it only ever grows by accident - a barrel export or a new | ||
| 341 | # module-scope import quietly pulls megabytes into the server graph. The | ||
| 342 | # budget is a ratchet just above today's size, not a discovered limit: | ||
| 343 | # when it trips, make the new dependency lazy rather than raising it. | ||
| 344 | START_CLOSURE_BUDGET_MB: 13.5 | ||
| 271 | steps: | 345 | steps: |
| 272 | - uses: actions/checkout@v4 | 346 | - uses: actions/checkout@v4 |
| 273 | 347 | ||
| @@ -283,41 +357,17 @@ jobs: · 9 unmodified lines | |||
| 283 | restore-keys: | | 357 | restore-keys: | |
| 284 | ${{ runner.os }}-bun-1.3.11- | 358 | ${{ runner.os }}-bun-1.3.11- |
| 285 | 359 | ||
| 286 | # The local scenarios boot a real `executor web` (which spawns a Node | ||
| 287 | # sidecar) and some drive a browser, so pin Node 24 and install Chromium. | ||
| 288 | - uses: actions/setup-node@v4 | 360 | - uses: actions/setup-node@v4 |
| 289 | with: | 361 | with: |
| 290 | node-version: 24 | 362 | node-version: 24 |
| 291 | 363 | ||
| 292 | - run: bun install --frozen-lockfile | 364 | - run: bun install --frozen-lockfile |
| 293 | 365 | ||
| 294 | - name: Cache Playwright browsers | 366 | - run: bun run build |
| 295 | uses: actions/cache@v4 | 367 | working-directory: apps/cloud |
| 296 | with: | ||
| 297 | path: ~/.cache/ms-playwright | ||
| 298 | key: ${{ runner.os }}-playwright-1.60.0 | ||
| 299 | restore-keys: | | ||
| 300 | ${{ runner.os }}-playwright- | ||
| 301 | 368 | ||
| 302 | # `chromium` and the new `chromium-headless-shell` ship as separate | 369 | - run: node scripts/start-closure.mjs |
| 303 | # downloads; the browser-driven scenarios launch the headless shell. | 370 | working-directory: apps/cloud |
| 304 | # Install from e2e so bunx resolves ITS pinned playwright (the version the | ||
| 305 | # tests run against) rather than floating to the latest, which would fetch | ||
| 306 | # a browser build the test runtime does not look for. | ||
| 307 | - name: Install Playwright Chromium | ||
| 308 | run: bunx playwright install --with-deps chromium chromium-headless-shell | ||
| 309 | working-directory: e2e | ||
| 310 | |||
| 311 | # The `local` project is excluded from the default `test` chain (each | ||
| 312 | # scenario boots its own `executor web`). Run just the stdio MCP scenario | ||
| 313 | # here: it is the auto-connect / env-as-secret regression guard, and | ||
| 314 | # running it alone avoids the boot-resource accumulation and the | ||
| 315 | # pre-existing browser flakiness of the rest of the local suite. Expanding | ||
| 316 | # to the full `local` project (bun run test:local) is a follow-up once | ||
| 317 | # those are stabilized. | ||
| 318 | - name: Run the stdio MCP scenario | ||
| 319 | run: bunx vitest run --project local local/stdio-mcp.test.ts | ||
| 320 | working-directory: e2e | ||
| 321 | 371 | ||
| 322 | desktop-smoke: | 372 | desktop-smoke: |
| 323 | name: Desktop smoke build | 373 | name: Desktop smoke build |
| @@ -369,7 +419,13 @@ jobs: · 45 unmodified lines | |||
| 369 | - name: Run sidecar outside the workspace | 419 | - name: Run sidecar outside the workspace |
| 370 | working-directory: apps/desktop | 420 | working-directory: apps/desktop |
| 371 | run: | | 421 | run: | |
| 372 | docker run --rm -d --name sidecar-smoke -p 45841:45841 -e HOME=/tmp \ | 422 | # Containers do not inherit the job environment. Keep the compiled |
| 423 | # product smoke from becoming a production analytics/integrations hit. | ||
| 424 | docker run --rm -d --name sidecar-smoke -p 45841:45841 \ | ||
| 425 | -e HOME=/tmp \ | ||
| 426 | -e DO_NOT_TRACK=1 \ | ||
| 427 | -e EXECUTOR_DISABLE_ANALYTICS=1 \ | ||
| 428 | -e EXECUTOR_DISABLE_INTEGRATIONS_FETCH=1 \ | ||
| 373 | -v "$PWD/resources/executor:/opt/executor:ro" \ | 429 | -v "$PWD/resources/executor:/opt/executor:ro" \ |
| 374 | debian:bookworm-slim /opt/executor/executor daemon run --foreground \ | 430 | debian:bookworm-slim /opt/executor/executor daemon run --foreground \ |
| 375 | --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke | 431 | --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke |
| @@ -414,4 +470,35 @@ jobs: · 38 unmodified lines | |||
| 414 | context: . | 470 | context: . |
| 415 | file: apps/host-selfhost/Dockerfile | 471 | file: apps/host-selfhost/Dockerfile |
| 416 | push: false | 472 | push: false |
| 473 | load: true | ||
| 417 | tags: executor-selfhost:ci | 474 | tags: executor-selfhost:ci |
| 475 | |||
| 476 | # Building alone doesn't catch startup crashes (e.g. a runtime-resolved | ||
| 477 | # package missing from the packaged runtime), so boot the container and | ||
| 478 | # require a healthy /api/health before calling the image good. | ||
| 479 | - name: Boot image and check health | ||
| 480 | shell: bash | ||
| 481 | run: | | ||
| 482 | set -euo pipefail | ||
| 483 | docker run -d --name selfhost-smoke -p 4788:4788 \ | ||
| 484 | -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \ | ||
| 485 | -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=smoke@example.com \ | ||
| 486 | -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -hex 16)" \ | ||
| 487 | -e EXECUTOR_WEB_BASE_URL=http://localhost:4788 \ | ||
| 488 | executor-selfhost:ci | ||
| 489 | for i in $(seq 1 30); do | ||
| 490 | if [ "$(docker inspect -f '{{.State.Running}}' selfhost-smoke)" != "true" ]; then | ||
| 491 | echo "container exited during startup" | ||
| 492 | docker logs selfhost-smoke | ||
| 493 | exit 1 | ||
| 494 | fi | ||
| 495 | if curl -fsS http://localhost:4788/api/health >/dev/null 2>&1; then | ||
| 496 | echo "healthy after ${i}s" | ||
| 497 | docker logs selfhost-smoke | ||
| 498 | exit 0 | ||
| 499 | fi | ||
| 500 | sleep 1 | ||
| 501 | done | ||
| 502 | echo "health check never passed" | ||
| 503 | docker logs selfhost-smoke | ||
| 504 | exit 1 | ||
| @@ -9,6 +9,16 @@ concurrency: · 8 unmodified lines | |||
| 9 | group: deploy-production | 9 | group: deploy-production |
| 10 | cancel-in-progress: false | 10 | cancel-in-progress: false |
| 11 | 11 | ||
| 12 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 13 | # than the ones that look like they run product code: the first pass guessed, | ||
| 14 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 15 | # where the product is not executed, so the blanket application is the cheap | ||
| 16 | # structural answer. | ||
| 17 | env: | ||
| 18 | DO_NOT_TRACK: "1" | ||
| 19 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 20 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 21 | |||
| 12 | jobs: | 22 | jobs: |
| 13 | migrate: | 23 | migrate: |
| 14 | name: Migrate database | 24 | name: Migrate database |
| @@ -77,12 +87,30 @@ jobs: · 62 unmodified lines | |||
| 77 | VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} | 87 | VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} |
| 78 | 88 | ||
| 79 | - name: Deploy cloud | 89 | - name: Deploy cloud |
| 80 | run: bun run wrangler deploy -c dist/server/wrangler.json | 90 | run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }} |
| 81 | working-directory: apps/cloud | 91 | working-directory: apps/cloud |
| 82 | env: | 92 | env: |
| 83 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | 93 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 84 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | 94 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 85 | 95 | ||
| 96 | # Deploy marker: one event per deploy into the same Axiom dataset the | ||
| 97 | # worker traces land in, so a latency step-change lines up with its | ||
| 98 | # deploy in one query. Skipped (not failed) when the secret is absent, | ||
| 99 | # and never blocks the deploy. | ||
| 100 | - name: Record deploy marker in Axiom | ||
| 101 | env: | ||
| 102 | AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }} | ||
| 103 | run: | | ||
| 104 | if [ -z "$AXIOM_INGEST_TOKEN" ]; then | ||
| 105 | echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker" | ||
| 106 | exit 0 | ||
| 107 | fi | ||
| 108 | curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \ | ||
| 109 | -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \ | ||
| 110 | -H "Content-Type: application/json" \ | ||
| 111 | -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \ | ||
| 112 | || echo "deploy marker ingest failed (non-blocking)" | ||
| 113 | |||
| 86 | deploy-marketing: | 114 | deploy-marketing: |
| 87 | name: Deploy marketing | 115 | name: Deploy marketing |
| 88 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 116 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -0,0 +1,47 @@ | |||
| 1 | # Refresh the Microsoft Graph slice release assets. | ||
| 2 | # | ||
| 3 | # The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers | ||
| 4 | # isolate, so the runtime reads per-selection slices published on the | ||
| 5 | # `graph-slices` release tag (see packages/plugins/openapi/src/providers/ | ||
| 6 | # microsoft/slices.ts). This workflow rebuilds the slices from the current | ||
| 7 | # upstream spec on a schedule and on demand. | ||
| 8 | name: Graph slices | ||
| 9 | |||
| 10 | on: | ||
| 11 | schedule: | ||
| 12 | # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes | ||
| 13 | # on a similar cadence. A failed run leaves the previous assets serving. | ||
| 14 | - cron: "17 6 * * 1" | ||
| 15 | workflow_dispatch: {} | ||
| 16 | |||
| 17 | permissions: | ||
| 18 | contents: write | ||
| 19 | |||
| 20 | jobs: | ||
| 21 | slices: | ||
| 22 | runs-on: ubuntu-latest | ||
| 23 | timeout-minutes: 20 | ||
| 24 | steps: | ||
| 25 | - uses: actions/checkout@v4 | ||
| 26 | |||
| 27 | - uses: oven-sh/setup-bun@v2 | ||
| 28 | with: | ||
| 29 | bun-version: 1.3.11 | ||
| 30 | |||
| 31 | - name: Install dependencies | ||
| 32 | run: bun install --frozen-lockfile | ||
| 33 | |||
| 34 | - name: Generate slices | ||
| 35 | working-directory: packages/plugins/openapi | ||
| 36 | run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices" | ||
| 37 | |||
| 38 | - name: Publish to the graph-slices release | ||
| 39 | env: | ||
| 40 | GH_TOKEN: ${{ github.token }} | ||
| 41 | run: | | ||
| 42 | gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ | ||
| 43 | gh release create graph-slices --repo "$GITHUB_REPOSITORY" \ | ||
| 44 | --title "Microsoft Graph slices" --latest=false \ | ||
| 45 | --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow." | ||
| 46 | gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \ | ||
| 47 | --repo "$GITHUB_REPOSITORY" --clobber | ||
| @@ -10,6 +10,16 @@ concurrency: · 9 unmodified lines | |||
| 10 | group: pkg-pr-new-${{ github.event.pull_request.number }} | 10 | group: pkg-pr-new-${{ github.event.pull_request.number }} |
| 11 | cancel-in-progress: true | 11 | cancel-in-progress: true |
| 12 | 12 | ||
| 13 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 14 | # than the ones that look like they run product code: the first pass guessed, | ||
| 15 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 16 | # where the product is not executed, so the blanket application is the cheap | ||
| 17 | # structural answer. | ||
| 18 | env: | ||
| 19 | DO_NOT_TRACK: "1" | ||
| 20 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 21 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 22 | |||
| 13 | jobs: | 23 | jobs: |
| 14 | # Per-platform matrix: build the executor binary, tar it, upload to R2. | 24 | # Per-platform matrix: build the executor binary, tar it, upload to R2. |
| 15 | # The wrapper npm package is built later by the `publish` job which just | 25 | # The wrapper npm package is built later by the `publish` job which just |
| @@ -16,6 +16,16 @@ permissions: · 15 unmodified lines | |||
| 16 | contents: read | 16 | contents: read |
| 17 | pull-requests: read | 17 | pull-requests: read |
| 18 | 18 | ||
| 19 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 20 | # than the ones that look like they run product code: the first pass guessed, | ||
| 21 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 22 | # where the product is not executed, so the blanket application is the cheap | ||
| 23 | # structural answer. | ||
| 24 | env: | ||
| 25 | DO_NOT_TRACK: "1" | ||
| 26 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 27 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 28 | |||
| 19 | jobs: | 29 | jobs: |
| 20 | sweep: | 30 | sweep: |
| 21 | name: Destroy previews for closed PRs | 31 | name: Destroy previews for closed PRs |
| @@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines | |||
| 23 | group: preview-${{ github.event.pull_request.number }} | 23 | group: preview-${{ github.event.pull_request.number }} |
| 24 | cancel-in-progress: ${{ github.event.action != 'closed' }} | 24 | cancel-in-progress: ${{ github.event.action != 'closed' }} |
| 25 | 25 | ||
| 26 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 27 | # than the ones that look like they run product code: the first pass guessed, | ||
| 28 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 29 | # where the product is not executed, so the blanket application is the cheap | ||
| 30 | # structural answer. | ||
| 31 | env: | ||
| 32 | DO_NOT_TRACK: "1" | ||
| 33 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 34 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 35 | |||
| 26 | jobs: | 36 | jobs: |
| 27 | deploy: | 37 | deploy: |
| 28 | name: Deploy preview | 38 | name: Deploy preview |
| @@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines | |||
| 21 | group: publish-desktop-${{ github.ref }} | 21 | group: publish-desktop-${{ github.ref }} |
| 22 | cancel-in-progress: false | 22 | cancel-in-progress: false |
| 23 | 23 | ||
| 24 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 25 | # production PostHog project from every e2e shard: the analytics layer | ||
| 26 | # (integration_added, execution_completed, artifact_*) and the | ||
| 27 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 28 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 29 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 30 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 31 | env: | ||
| 32 | DO_NOT_TRACK: "1" | ||
| 33 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 34 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 35 | |||
| 24 | jobs: | 36 | jobs: |
| 25 | build: | 37 | build: |
| 26 | permissions: | 38 | permissions: |
| @@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines | |||
| 21 | group: publish-executor-package-${{ github.ref }} | 21 | group: publish-executor-package-${{ github.ref }} |
| 22 | cancel-in-progress: false | 22 | cancel-in-progress: false |
| 23 | 23 | ||
| 24 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 25 | # than the ones that look like they run product code: the first pass guessed, | ||
| 26 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 27 | # where the product is not executed, so the blanket application is the cheap | ||
| 28 | # structural answer. | ||
| 29 | env: | ||
| 30 | DO_NOT_TRACK: "1" | ||
| 31 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 32 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 33 | |||
| 24 | jobs: | 34 | jobs: |
| 25 | publish: | 35 | publish: |
| 26 | runs-on: ubuntu-latest | 36 | runs-on: ubuntu-latest |
| @@ -62,7 +72,9 @@ jobs: · 35 unmodified lines | |||
| 62 | 72 | ||
| 63 | - name: Update npm for trusted publishing | 73 | - name: Update npm for trusted publishing |
| 64 | run: | | 74 | run: | |
| 65 | npm install -g npm@latest | 75 | # npm 12.0.0 fails `publish --provenance` with "Cannot find module |
| 76 | # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works. | ||
| 77 | npm install -g npm@11 | ||
| 66 | npm --version | 78 | npm --version |
| 67 | 79 | ||
| 68 | - name: Install dependencies | 80 | - name: Install dependencies |
| @@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines | |||
| 16 | group: publish-selfhost-docker-${{ inputs.tag }} | 16 | group: publish-selfhost-docker-${{ inputs.tag }} |
| 17 | cancel-in-progress: false | 17 | cancel-in-progress: false |
| 18 | 18 | ||
| 19 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 20 | # production PostHog project from every e2e shard: the analytics layer | ||
| 21 | # (integration_added, execution_completed, artifact_*) and the | ||
| 22 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 23 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 24 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 25 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 26 | env: | ||
| 27 | DO_NOT_TRACK: "1" | ||
| 28 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 29 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 30 | |||
| 19 | jobs: | 31 | jobs: |
| 20 | metadata: | 32 | metadata: |
| 21 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 33 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -163,16 +175,124 @@ jobs: · 141 unmodified lines | |||
| 163 | if-no-files-found: error | 175 | if-no-files-found: error |
| 164 | retention-days: 1 | 176 | retention-days: 1 |
| 165 | 177 | ||
| 178 | # Release gate: run the full selfhost-docker e2e project against the exact | ||
| 179 | # by-digest image each build leg pushed, before any user-visible tag moves. | ||
| 180 | # Build-only smoke misses startup crashes and feature-path breaks (a package | ||
| 181 | # resolved at runtime but missing from the packaged runtime shipped broken in | ||
| 182 | # v1.5.30 exactly this way); per-arch matters too — the libsql native-addon | ||
| 183 | # bug was arm64-only. | ||
| 184 | e2e: | ||
| 185 | needs: | ||
| 186 | - metadata | ||
| 187 | - build | ||
| 188 | strategy: | ||
| 189 | fail-fast: false | ||
| 190 | matrix: | ||
| 191 | include: | ||
| 192 | - arch: amd64 | ||
| 193 | runner: blacksmith-4vcpu-ubuntu-2404 | ||
| 194 | - arch: arm64 | ||
| 195 | runner: blacksmith-4vcpu-ubuntu-2404-arm | ||
| 196 | runs-on: ${{ matrix.runner }} | ||
| 197 | timeout-minutes: 40 | ||
| 198 | permissions: | ||
| 199 | contents: read | ||
| 200 | packages: read | ||
| 201 | |||
| 202 | steps: | ||
| 203 | - name: Checkout release tag | ||
| 204 | uses: actions/checkout@v4 | ||
| 205 | with: | ||
| 206 | ref: ${{ needs.metadata.outputs.release_tag }} | ||
| 207 | persist-credentials: false | ||
| 208 | |||
| 209 | - uses: oven-sh/setup-bun@v2 | ||
| 210 | with: | ||
| 211 | bun-version: 1.3.11 | ||
| 212 | |||
| 213 | - name: Cache Bun package cache | ||
| 214 | uses: actions/cache@v4 | ||
| 215 | with: | ||
| 216 | path: ~/.bun/install/cache | ||
| 217 | key: ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-${{ hashFiles('bun.lock') }} | ||
| 218 | restore-keys: | | ||
| 219 | ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11- | ||
| 220 | |||
| 221 | # Scenario helpers spawn Node sidecars; pin the same Node 24 runtime the | ||
| 222 | # other workflows use. | ||
| 223 | - uses: actions/setup-node@v4 | ||
| 224 | with: | ||
| 225 | node-version: 24 | ||
| 226 | |||
| 227 | - run: bun install --frozen-lockfile | ||
| 228 | |||
| 229 | - name: Cache Playwright browsers | ||
| 230 | uses: actions/cache@v4 | ||
| 231 | with: | ||
| 232 | path: ~/.cache/ms-playwright | ||
| 233 | key: ${{ runner.os }}-${{ runner.arch }}-playwright-1.60.0 | ||
| 234 | restore-keys: | | ||
| 235 | ${{ runner.os }}-${{ runner.arch }}-playwright- | ||
| 236 | |||
| 237 | # Install from e2e so bunx resolves ITS pinned playwright (the version | ||
| 238 | # the tests run against) rather than floating to the latest. | ||
| 239 | - name: Install Playwright Chromium | ||
| 240 | run: bunx playwright install --with-deps chromium chromium-headless-shell | ||
| 241 | working-directory: e2e | ||
| 242 | |||
| 243 | - name: Log in to GHCR | ||
| 244 | uses: docker/login-action@v3 | ||
| 245 | with: | ||
| 246 | registry: ghcr.io | ||
| 247 | username: ${{ github.actor }} | ||
| 248 | password: ${{ secrets.GITHUB_TOKEN }} | ||
| 249 | |||
| 250 | - name: Download image digest | ||
| 251 | uses: actions/download-artifact@v4 | ||
| 252 | with: | ||
| 253 | name: digests-${{ matrix.arch }} | ||
| 254 | path: ${{ runner.temp }}/digests | ||
| 255 | |||
| 256 | - name: Resolve image reference | ||
| 257 | id: image_ref | ||
| 258 | shell: bash | ||
| 259 | env: | ||
| 260 | IMAGE: ${{ needs.metadata.outputs.image }} | ||
| 261 | run: | | ||
| 262 | set -euo pipefail | ||
| 263 | digest_file="$(find "$RUNNER_TEMP/digests" -maxdepth 1 -type f | head -n 1)" | ||
| 264 | if [ -z "$digest_file" ]; then | ||
| 265 | echo "No image digest was downloaded." | ||
| 266 | exit 1 | ||
| 267 | fi | ||
| 268 | ref="${IMAGE}@sha256:$(basename "$digest_file")" | ||
| 269 | echo "ref=$ref" >> "$GITHUB_OUTPUT" | ||
| 270 | docker pull "$ref" | ||
| 271 | |||
| 272 | - name: Run selfhost-docker scenarios against the published digest | ||
| 273 | env: | ||
| 274 | E2E_SELFHOST_DOCKER_IMAGE: ${{ steps.image_ref.outputs.ref }} | ||
| 275 | run: bunx vitest run --project selfhost-docker --retry=2 | ||
| 276 | working-directory: e2e | ||
| 277 | |||
| 278 | - name: Upload run artifacts | ||
| 279 | if: failure() | ||
| 280 | uses: actions/upload-artifact@v4 | ||
| 281 | with: | ||
| 282 | name: e2e-runs-selfhost-docker-${{ matrix.arch }} | ||
| 283 | path: | | ||
| 284 | e2e/runs/ | ||
| 285 | e2e/selfhost-docker.boot.log | ||
| 286 | retention-days: 7 | ||
| 287 | |||
| 166 | merge: | 288 | merge: |
| 167 | needs: | 289 | needs: |
| 168 | - metadata | 290 | - metadata |
| 169 | - build | 291 | - build |
| 292 | - e2e | ||
| 170 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 293 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 171 | permissions: | 294 | permissions: |
| 172 | packages: write | 295 | packages: write |
| 173 | env: | ||
| 174 | HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | ||
| 175 | |||
| 176 | steps: | 296 | steps: |
| 177 | - name: Download image digests | 297 | - name: Download image digests |
| 178 | uses: actions/download-artifact@v4 | 298 | uses: actions/download-artifact@v4 |
| @@ -219,10 +339,28 @@ jobs: · 40 unmodified lines | |||
| 219 | 339 | ||
| 220 | docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" | 340 | docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" |
| 221 | 341 | ||
| 342 | # Mirrors each release to the pre-org-move namespace | ||
| 343 | # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect. | ||
| 344 | # Deliberately a separate job: the canonical publish above never depends on | ||
| 345 | # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows | ||
| 346 | # as one red job pointing at exactly what to fix. | ||
| 347 | mirror-legacy: | ||
| 348 | needs: | ||
| 349 | - metadata | ||
| 350 | - merge | ||
| 351 | runs-on: blacksmith-4vcpu-ubuntu-2404 | ||
| 352 | env: | ||
| 353 | HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | ||
| 354 | |||
| 355 | steps: | ||
| 222 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) | 356 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) |
| 223 | if: env.HAS_LEGACY_TOKEN != 'true' | 357 | if: env.HAS_LEGACY_TOKEN != 'true' |
| 224 | run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." | 358 | run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." |
| 225 | 359 | ||
| 360 | - name: Set up Docker Buildx | ||
| 361 | if: env.HAS_LEGACY_TOKEN == 'true' | ||
| 362 | uses: docker/setup-buildx-action@v3 | ||
| 363 | |||
| 226 | - name: Log in to legacy GHCR namespace | 364 | - name: Log in to legacy GHCR namespace |
| 227 | if: env.HAS_LEGACY_TOKEN == 'true' | 365 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 228 | uses: docker/login-action@v3 | 366 | uses: docker/login-action@v3 |
| @@ -231,7 +369,7 @@ jobs: · 2 unmodified lines | |||
| 231 | username: rhyssullivan | 369 | username: rhyssullivan |
| 232 | password: ${{ secrets.GHCR_LEGACY_TOKEN }} | 370 | password: ${{ secrets.GHCR_LEGACY_TOKEN }} |
| 233 | 371 | ||
| 234 | - name: Mirror self-host image to legacy GHCR namespace | 372 | - name: Mirror release tags to the legacy namespace |
| 235 | if: env.HAS_LEGACY_TOKEN == 'true' | 373 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 236 | shell: bash | 374 | shell: bash |
| 237 | env: | 375 | env: |
| @@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines | |||
| 13 | group: release-${{ github.ref }} | 13 | group: release-${{ github.ref }} |
| 14 | cancel-in-progress: false | 14 | cancel-in-progress: false |
| 15 | 15 | ||
| 16 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 17 | # production PostHog project from every e2e shard: the analytics layer | ||
| 18 | # (integration_added, execution_completed, artifact_*) and the | ||
| 19 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 20 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 21 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 22 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 23 | env: | ||
| 24 | DO_NOT_TRACK: "1" | ||
| 25 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 26 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 27 | |||
| 16 | jobs: | 28 | jobs: |
| 17 | release: | 29 | release: |
| 18 | runs-on: ubuntu-latest | 30 | runs-on: ubuntu-latest |
| @@ -42,7 +54,9 @@ jobs: · 23 unmodified lines | |||
| 42 | 54 | ||
| 43 | - name: Update npm for trusted publishing | 55 | - name: Update npm for trusted publishing |
| 44 | run: | | 56 | run: | |
| 45 | npm install -g npm@latest | 57 | # npm 12.0.0 fails `publish --provenance` with "Cannot find module |
| 58 | # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works. | ||
| 59 | npm install -g npm@11 | ||
| 46 | npm --version | 60 | npm --version |
| 47 | 61 | ||
| 48 | - name: Install dependencies | 62 | - name: Install dependencies |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | |||
| 13 | "*.tsbuildinfo", | 13 | "*.tsbuildinfo", |
| 14 | "executor-*.tgz", | 14 | "executor-*.tgz", |
| 15 | "**/routeTree.gen.ts", | 15 | "**/routeTree.gen.ts", |
| 16 | "**/smoke-harness-bundle.gen.ts", | ||
| 16 | "apps/cloud/src/services/executor-schema.ts" | 17 | "apps/cloud/src/services/executor-schema.ts" |
| 17 | ] | 18 | ] |
| 18 | } | 19 | } |
| @@ -64,6 +64,7 @@ · 63 unmodified lines | |||
| 64 | "apps/desktop/src/main.ts", | 64 | "apps/desktop/src/main.ts", |
| 65 | "scripts/**/*.{ts,js}", | 65 | "scripts/**/*.{ts,js}", |
| 66 | "apps/*/scripts/**/*.{ts,js}", | 66 | "apps/*/scripts/**/*.{ts,js}", |
| 67 | "packages/*/*/scripts/**/*.{ts,js}", | ||
| 67 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", | 68 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", |
| 68 | ], | 69 | ], |
| 69 | "rules": { | 70 | "rules": { |
| @@ -119,6 +120,31 @@ · 49 unmodified lines | |||
| 119 | "executor/no-try-catch-or-throw": "off", | 120 | "executor/no-try-catch-or-throw": "off", |
| 120 | }, | 121 | }, |
| 121 | }, | 122 | }, |
| 123 | { | ||
| 124 | // boundary: the MCP-Apps shell is browser code, not Effect domain code. | ||
| 125 | // It runs inside a sandboxed iframe and talks to the host over | ||
| 126 | // postMessage and the MCP bridge — both of which are throw/Promise APIs | ||
| 127 | // with no Effect runtime present. `new Function` evaluation of generated | ||
| 128 | // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire | ||
| 129 | // all surface untyped JS errors that must be caught and rendered rather | ||
| 130 | // than modelled. Scoped to the iframe payload plus its build config; the | ||
| 131 | // package's Effect-facing loader (`src/shell-html.ts`) is deliberately | ||
| 132 | // NOT listed and uses inline suppressions instead. | ||
| 133 | "files": [ | ||
| 134 | "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", | ||
| 135 | "packages/hosts/mcp-apps-shell/vite.config.shell.ts", | ||
| 136 | ], | ||
| 137 | "rules": { | ||
| 138 | "executor/no-double-cast": "off", | ||
| 139 | "executor/no-error-constructor": "off", | ||
| 140 | "executor/no-instanceof-error": "off", | ||
| 141 | "executor/no-json-parse": "off", | ||
| 142 | "executor/no-promise-catch": "off", | ||
| 143 | "executor/no-promise-reject": "off", | ||
| 144 | "executor/no-try-catch-or-throw": "off", | ||
| 145 | "executor/no-unknown-error-message": "off", | ||
| 146 | }, | ||
| 147 | }, | ||
| 122 | { | 148 | { |
| 123 | "files": ["packages/kernel/core/src/code-recovery.ts"], | 149 | "files": ["packages/kernel/core/src/code-recovery.ts"], |
| 124 | "rules": { | 150 | "rules": { |
| @@ -1,83 +1,58 @@ | |||
| 1 | # AGENTS.md | 1 | # Executor policy |
| 2 | 2 | ||
| 3 | Principles: the contracts that stay true while implementations churn. For how to | 3 | Stable contracts live here. Current setup and server mechanics are in |
| 4 | run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for | 4 | [RUNNING.md](RUNNING.md); e2e invariants are in |
| 5 | e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in | 5 | [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift. |
| 6 | any fresh checkout or worktree. | ||
| 7 | 6 | ||
| 8 | ## Task Completion | 7 | ## Verification and evidence |
| 9 | 8 | ||
| 10 | - Tests use Effect Vitest. Run scoped tests with `vitest run ...`. `bun run test` | 9 | - Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the |
| 11 | is fine (it delegates to Vitest); NEVER run `bun test`. | 10 | package script. Never use `bun test`. |
| 12 | - Run the narrowest useful verification for a change. For broad or merge-ready | 11 | - Use the narrowest meaningful verification while iterating. Merge-ready gates |
| 13 | work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`. | 12 | are `bun run format:check`, `lint`, `typecheck`, and `test`. |
| 14 | - Run `bun run format` before opening a PR, staging only files your branch | 13 | - e2e is slow. Avoid running it often during development: optimize for |
| 15 | touched (leave unrelated files `format` rewrites out of the PR). | 14 | iteration speed first, then test. Save e2e for when the change is settled, |
| 15 | and run the narrowest scenario rather than the full suite. | ||
| 16 | - Run `bun run format` before a PR and include only files owned by the branch. | ||
| 17 | - User-visible work requires a specific e2e run, a browsable dev instance, and | ||
| 18 | the recording or trace showing what to inspect. Add a scenario when none | ||
| 19 | covers the changed behavior. | ||
| 16 | 20 | ||
| 17 | ## Handing Back Work: Evidence, Not Assertions | 21 | ## Service emulators |
| 18 | 22 | ||
| 19 | "Done" is something the user can open, not a claim. When work changes what a user | 23 | Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use |
| 20 | sees, hand back: an e2e run that proves it (link the specific run with one line | 24 | the published `@executor-js/emulate` emulators rather than stubs. They provide |
| 21 | on what to look at, not a wall of green), the dev server left running so they can | 25 | wire-level state, real-shaped credentials, OpenAPI descriptions, and request |
| 22 | poke at it, and the paths worth trying by hand including ones no scenario covers | 26 | ledgers. Create per-run hosted instances through the service's |
| 23 | yet. If no scenario covers the change, write one, and embed its recording in the | 27 | `/_emulate/instances` control route. |
| 24 | PR. The machinery runs in reverse too: seed an environment into a state | ||
| 25 | (reproduce a bug live, stage data) and hand across the link. | ||
| 26 | 28 | ||
| 27 | ## Service Emulators | 29 | Emulate is a separate project. Make emulator changes there and consume the |
| 30 | published package here; never re-vendor or create a parallel fake inside | ||
| 31 | executor. | ||
| 28 | 32 | ||
| 29 | For any test or demo needing an upstream API, OAuth/OIDC provider, or webhook | 33 | ## Engineering boundaries |
| 30 | source, use the `@executor-js/emulate` emulators instead of writing a stub: | ||
| 31 | wire-level and stateful, real SDKs run unmodified, each serves a full OpenAPI | ||
| 32 | spec, mints real-shaped credentials, and records every call in a request ledger | ||
| 33 | to assert against. Hosted at `https://<service>.emulators.dev`. See the `emulate` | ||
| 34 | skill (`.claude/skills/emulate/SKILL.md`). They are a standalone project | ||
| 35 | (`github.com/UsefulSoftwareCo/emulate`) consumed here as the published package: | ||
| 36 | full autonomy to change, publish, and deploy them on their `main`; don't re-vendor. | ||
| 37 | |||
| 38 | ## Attribution | ||
| 39 | |||
| 40 | No AI/Claude/Anthropic/Co-Authored-By attribution in commits, messages, PRs, or | ||
| 41 | generated files. PR titles and descriptions go to a public repo: no internal info | ||
| 42 | or specific names unless explicitly stated. | ||
| 43 | |||
| 44 | ## Collaboration Notes | ||
| 45 | |||
| 46 | - The user uses speech-to-text; infer likely intent from odd wording, ask only | ||
| 47 | when needed. | ||
| 48 | - Code is cheap to write: no time estimates, implementation time isn't a blocker. | ||
| 49 | - Never use em-dashes anywhere. Use commas, colons, parentheses, or separate | ||
| 50 | sentences. | ||
| 51 | |||
| 52 | ## Reference Repos | ||
| 53 | |||
| 54 | Repos in `.reference` (Effect, effect-atom, …) are available for patterns. Clone | ||
| 55 | a given Git URL into `.reference` and pull latest before using it. | ||
| 56 | |||
| 57 | ## Engineering Priorities | ||
| 58 | 34 | ||
| 59 | - Prefer correctness and predictable behavior over short-term convenience. | 35 | - Prefer correctness and predictable behavior over short-term convenience. |
| 60 | - Preserve runtime behavior when changing lint, typing, or test structure. | 36 | - Preserve runtime behavior during lint, typing, and test-structure changes. |
| 61 | - Keep package boundaries clear; use public package exports, not cross-package | 37 | - Use public package exports; never cross package boundaries with relative |
| 62 | relative imports. | 38 | imports. |
| 63 | - Extract shared logic only when the shared behavior is real; avoid broad generic | 39 | - Extract shared logic only for genuinely shared behavior. Avoid generic |
| 64 | abstractions for one-off duplication. | 40 | abstractions for one-off duplication. |
| 41 | - Public PRs, commits, generated files, and documentation contain no private | ||
| 42 | names, internal context, customer-derived data, or AI attribution. | ||
| 65 | 43 | ||
| 66 | ## Package Roles | 44 | ## Package ownership |
| 67 | 45 | ||
| 68 | - `packages/core/sdk`: core contracts, plugin wiring, scopes, sources, secrets, | 46 | - `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets, |
| 69 | policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method | 47 | policies, and fixtures. Shared HTTP auth vocabulary lives in |
| 70 | vocabulary the HTTP protocol plugins compose (core never imports it, keeping it | 48 | `@executor-js/sdk/http-auth`; core remains carrier-agnostic. |
| 71 | carrier-agnostic). | ||
| 72 | - `packages/core/storage-*`: storage adapters and test support. | 49 | - `packages/core/storage-*`: storage adapters and test support. |
| 73 | - `packages/plugins/*`: protocol and provider plugins; their runtime, React, API, | 50 | - `packages/plugins/*`: protocol/provider implementations and their runtime, |
| 74 | and testing helpers live with the owning plugin. | 51 | React, API, and testing helpers. |
| 75 | - `packages/react`: shared React UI and atom/client integration. | 52 | - `packages/react`: shared React UI and client/atom integration. |
| 76 | - `packages/hosts/mcp`: MCP host surface. | 53 | - `packages/hosts/mcp`: MCP host surface. |
| 77 | - `packages/kernel/*`: execution runtimes and code execution substrate. | 54 | - `packages/kernel/*`: execution runtimes and code-execution substrate. |
| 78 | - `apps/{local,cloud,cli,desktop}`: product entry points composing the packages. | 55 | - `apps/{local,cloud,cli,desktop}`: product composition roots. |
| 79 | |||
| 80 | ## Other | ||
| 81 | 56 | ||
| 82 | Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env | 57 | Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and |
| 83 | learnings in LEARNINGS.md. | 58 | environment discoveries in `LEARNINGS.md`. |
| @@ -8,7 +8,7 @@ and per-tool policies, then use that one catalog from any MCP-compatible agent. · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) | 9 | [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) |
| 10 | 10 | ||
| 11 | [https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad) | 11 |  |
| 12 | 12 | ||
| 13 | ## Why Executor | 13 | ## Why Executor |
| 14 | 14 | ||
| @@ -21,9 +21,9 @@ set its policy once, and every agent shares it over MCP. Your integrations, · 6 unmodified lines | |||
| 21 | auth, and policies live in one place instead of being scattered across each | 21 | auth, and policies live in one place instead of being scattered across each |
| 22 | client. | 22 | client. |
| 23 | 23 | ||
| 24 | - **Any source.** First-party support for MCP servers, OpenAPI, GraphQL, and | 24 | - **Any integration.** First-party support for MCP servers, OpenAPI, GraphQL, and |
| 25 | Google Discovery. If you can describe it with a JSON schema, it can be an | 25 | Google Discovery. If you can describe it with a JSON schema, it can be an |
| 26 | integration. The plugin system is open to any source type. | 26 | integration. The plugin system is open to any integration type. |
| 27 | - **One catalog, every agent.** Anything MCP-compatible connects to the same | 27 | - **One catalog, every agent.** Anything MCP-compatible connects to the same |
| 28 | set of tools. | 28 | set of tools. |
| 29 | - **Governed by policy.** Each tool is allowed, gated behind approval, or | 29 | - **Governed by policy.** Each tool is allowed, gated behind approval, or |
| @@ -100,12 +100,12 @@ appear. · 70 unmodified lines | |||
| 100 | 100 | ||
| 101 | ## Add an integration | 101 | ## Add an integration |
| 102 | 102 | ||
| 103 | From the web UI, click **Add Source**, paste an OpenAPI, GraphQL, or MCP URL, | 103 | From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL, |
| 104 | and Executor detects the type, indexes the tools, and handles auth. Or add one | 104 | and Executor detects the type, indexes the tools, and handles auth. Or add one |
| 105 | from the CLI: | 105 | from the CLI: |
| 106 | 106 | ||
| 107 | ```bash | 107 | ```bash |
| 108 | executor call executor openapi addSource '{ | 108 | executor call executor openapi addIntegration '{ |
| 109 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", | 109 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", |
| 110 | "namespace": "petstore", | 110 | "namespace": "petstore", |
| 111 | "baseUrl": "https://petstore3.swagger.io/api/v3" | 111 | "baseUrl": "https://petstore3.swagger.io/api/v3" |
| @@ -113,7 +113,7 @@ executor call executor openapi addSource '{ · 1 unmodified line | |||
| 113 | ``` | 113 | ``` |
| 114 | 114 | ||
| 115 | Use `baseUrl` when the OpenAPI document has relative `servers` entries (for | 115 | Use `baseUrl` when the OpenAPI document has relative `servers` entries (for |
| 116 | example `"/api/v3"`). Confirm it is live with `executor tools sources`. | 116 | example `"/api/v3"`). Confirm it is live with `executor tools integrations`. |
| 117 | 117 | ||
| 118 | ## Using tools | 118 | ## Using tools |
| 119 | 119 | ||
| @@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines | |||
| 169 | executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces | 169 | executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces |
| 170 | executor resume --execution-id <id> # resume paused execution | 170 | executor resume --execution-id <id> # resume paused execution |
| 171 | executor tools search "<query>" # search tools by intent | 171 | executor tools search "<query>" # search tools by intent |
| 172 | executor tools sources # list configured sources + tool counts | 172 | executor tools integrations # list configured integrations + tool counts |
| 173 | executor tools describe <path> # show tool TypeScript/JSON schema | 173 | executor tools describe <path> # show tool TypeScript/JSON schema |
| 174 | ``` | 174 | ``` |
| 175 | 175 | ||
| @@ -190,7 +190,7 @@ apps/ · 14 unmodified lines | |||
| 190 | packages/ | 190 | packages/ |
| 191 | core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core | 191 | core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core |
| 192 | kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker) | 192 | kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker) |
| 193 | plugins/ source and provider plugins (openapi, graphql, mcp, google, | 193 | plugins/ integration and provider plugins (openapi, graphql, mcp, google, |
| 194 | microsoft, 1password, keychain, encrypted/file secrets, ...) | 194 | microsoft, 1password, keychain, encrypted/file secrets, ...) |
| 195 | hosts/ host adapters (MCP surface, Cloudflare) | 195 | hosts/ host adapters (MCP surface, Cloudflare) |
| 196 | react/ shared React UI | 196 | react/ shared React UI |
| @@ -0,0 +1,101 @@ | |||
| 1 | # Telemetry | ||
| 2 | |||
| 3 | Executor's local products (the CLI, the desktop app) and self-hosted | ||
| 4 | deployments send a small set of anonymous usage events. This document is the | ||
| 5 | complete account of what is sent, what is deliberately not sent, why the | ||
| 6 | feature exists, and how to turn it off. | ||
| 7 | |||
| 8 | The hosted cloud product has its own browser-side analytics, disclosed | ||
| 9 | separately in its terms; this document covers the software that runs on your | ||
| 10 | machines. | ||
| 11 | |||
| 12 | ## Why | ||
| 13 | |||
| 14 | Executor is used mostly outside our infrastructure. Without some signal from | ||
| 15 | local and self-hosted installs, every product decision about them is a guess: | ||
| 16 | we cannot tell whether a feature ships broken, whether anyone uses toolkits, | ||
| 17 | whether executions fail at unusual rates after a release, or whether the | ||
| 18 | product is growing anywhere except cloud. | ||
| 19 | |||
| 20 | The events exist to answer exactly one kind of question: **how are product | ||
| 21 | features being used?** They are metadata about the product, not about you. | ||
| 22 | Anything that would answer "what is this user doing" — which APIs you call, | ||
| 23 | what your tools are named, what your code does — is out of scope by design, | ||
| 24 | not by omission. | ||
| 25 | |||
| 26 | ## What is sent | ||
| 27 | |||
| 28 | Each event carries its named properties plus, on every event: a random | ||
| 29 | per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the | ||
| 30 | release channel, and the app version. | ||
| 31 | |||
| 32 | | Event | Properties | Meaning | | ||
| 33 | | --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 34 | | `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. | | ||
| 35 | | `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). | | ||
| 36 | | `integration_removed` | `plugin_key` | An integration was removed, by kind. | | ||
| 37 | | `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. | | ||
| 38 | | `artifact_viewed` | `via` | An artifact was opened for its content. | | ||
| 39 | | `artifact_updated` | `via` | An artifact was overwritten or renamed. | | ||
| 40 | | `artifact_deleted` | `via` | An artifact was deleted. | | ||
| 41 | |||
| 42 | That table is exhaustive. The typed catalog the code compiles against is | ||
| 43 | [`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts); | ||
| 44 | an event that is not in that file cannot be sent. | ||
| 45 | |||
| 46 | ## What is never sent | ||
| 47 | |||
| 48 | - No code, tool arguments, tool results, or error messages. | ||
| 49 | - No secrets, tokens, or credentials. | ||
| 50 | - No names you typed: no integration slugs, connection names, toolkit slugs, | ||
| 51 | tool names, or artifact titles. The **kind** of integration (`openapi`, | ||
| 52 | `mcp`, ...) is a product question; **which** service you connected it to is | ||
| 53 | your business. | ||
| 54 | - No identity: no emails, usernames, hostnames, IP-derived location, or org | ||
| 55 | names. Events are marked so the analytics backend builds no person profile. | ||
| 56 | |||
| 57 | ## The anonymous id | ||
| 58 | |||
| 59 | A random UUID is minted the first time the daemon or server starts and stored | ||
| 60 | as `analytics-id` in the data directory (`~/.executor` for local installs, the | ||
| 61 | configured data dir for self-host). It exists so that ten events from one | ||
| 62 | install count as one install, not ten. It is not derived from your machine, | ||
| 63 | account, or network, and deleting the file resets it. When telemetry is | ||
| 64 | disabled the file is never created. | ||
| 65 | |||
| 66 | ## Opting out | ||
| 67 | |||
| 68 | Set either environment variable to `1`, `true`, or `yes`: | ||
| 69 | |||
| 70 | - `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com), | ||
| 71 | which Executor also honors for its other outbound calls (crash reporting, | ||
| 72 | the integrations.sh catalog fetch). | ||
| 73 | - `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog | ||
| 74 | fetch and crash reporting to keep working. | ||
| 75 | |||
| 76 | Opting out is total: the analytics service becomes a no-op, nothing is | ||
| 77 | buffered, nothing is sent, and no id file is written. The CLI's managed | ||
| 78 | service (launchd/systemd) forwards both variables into the supervised | ||
| 79 | daemon's environment, so an opted-out install stays opted out. | ||
| 80 | |||
| 81 | ## Delivery mechanics | ||
| 82 | |||
| 83 | Events buffer in memory (bounded) and flush in batches on a fixed cadence, | ||
| 84 | plus once at shutdown. Delivery is best-effort: a failed flush re-queues and | ||
| 85 | retries later, failures are swallowed, and no user-facing operation ever | ||
| 86 | waits on — or can be failed by — analytics. The ingest endpoint is PostHog | ||
| 87 | (`us.i.posthog.com`); the project key in the source identifies the project | ||
| 88 | and grants no read access. | ||
| 89 | |||
| 90 | ## Where the line is enforced | ||
| 91 | |||
| 92 | Structurally, not by convention: | ||
| 93 | |||
| 94 | - The event catalog is a closed, typed interface — adding a property means | ||
| 95 | editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed | ||
| 96 | change, next to the property rules at the top of that file. | ||
| 97 | - Attribution (`plane`, `via`) is bound where each serving surface is | ||
| 98 | composed, so events cannot carry request-controlled labels. | ||
| 99 | - Observers hang off neutral seams (an engine wrapper, post-commit hooks); the | ||
| 100 | core SDK carries no analytics vocabulary and hosts that do not opt in — like | ||
| 101 | every test — compose with no analytics at all. | ||
| @@ -1,5 +1,244 @@ | |||
| 1 | # executor | 1 | # executor |
| 2 | 2 | ||
| 3 | ## 1.6.0 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1737](https://github.com/UsefulSoftwareCo/executor/pull/1737) [`9296f36`](https://github.com/UsefulSoftwareCo/executor/commit/9296f36a8adbfdeec700ce33c37987127857b2fd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Scope the `skills` tool to Executor's own documentation. Its description, argument, index, and unknown-name error now state that it serves a fixed catalog of how-to docs for this server's tools, so an agent on a host without a skill tool of its own no longer reads it as a general reader for the harness's or the user's skills. | ||
| 8 | |||
| 9 | - Updated dependencies [[`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49)]: | ||
| 10 | - @executor-js/sdk@1.6.0 | ||
| 11 | - @executor-js/local@1.6.0 | ||
| 12 | - @executor-js/api@1.4.63 | ||
| 13 | - @executor-js/runtime-quickjs@1.6.0 | ||
| 14 | |||
| 15 | ## 1.5.42 | ||
| 16 | |||
| 17 | ### Patch Changes | ||
| 18 | |||
| 19 | - Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: | ||
| 20 | - @executor-js/sdk@1.5.42 | ||
| 21 | - @executor-js/local@1.5.42 | ||
| 22 | - @executor-js/api@1.4.62 | ||
| 23 | - @executor-js/runtime-quickjs@1.5.42 | ||
| 24 | |||
| 25 | ## 1.5.41 | ||
| 26 | |||
| 27 | ### Patch Changes | ||
| 28 | |||
| 29 | - [#1600](https://github.com/UsefulSoftwareCo/executor/pull/1600) [`1b5f931`](https://github.com/UsefulSoftwareCo/executor/commit/1b5f931d90b52fa9eca7b6f53359a117d757c7c1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Add `integrations.remove` to the core tools so an agent can drop a catalog integration** | ||
| 30 | |||
| 31 | `integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. | ||
| 32 | |||
| 33 | The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. | ||
| 34 | |||
| 35 | - [#1556](https://github.com/UsefulSoftwareCo/executor/pull/1556) [`f674fb8`](https://github.com/UsefulSoftwareCo/executor/commit/f674fb80eebd597f922edd5ec21b8035ab195a78) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out** | ||
| 36 | |||
| 37 | The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour. | ||
| 38 | |||
| 39 | - [#1603](https://github.com/UsefulSoftwareCo/executor/pull/1603) [`624e85f`](https://github.com/UsefulSoftwareCo/executor/commit/624e85f033632a7624c2bddf0944112166b1f481) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `oauth.clients.remove` reported success for clients it never removed** | ||
| 40 | |||
| 41 | The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion. | ||
| 42 | |||
| 43 | This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone. | ||
| 44 | |||
| 45 | The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent. | ||
| 46 | |||
| 47 | - Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: | ||
| 48 | - @executor-js/sdk@1.5.41 | ||
| 49 | - @executor-js/local@1.5.41 | ||
| 50 | - @executor-js/api@1.4.61 | ||
| 51 | - @executor-js/runtime-quickjs@1.5.41 | ||
| 52 | |||
| 53 | ## 1.5.40 | ||
| 54 | |||
| 55 | ### Patch Changes | ||
| 56 | |||
| 57 | - [#1528](https://github.com/UsefulSoftwareCo/executor/pull/1528) [`676af1a`](https://github.com/UsefulSoftwareCo/executor/commit/676af1a78301d83cdab52b0389b4c67ed07ae872) Thanks [@baggiiiie](https://github.com/baggiiiie)! - Prevent execute agents from attempting unsupported base64 decoding by directing file payloads through ToolFile emission and bodyBase64 forwarding. | ||
| 58 | |||
| 59 | - [#1545](https://github.com/UsefulSoftwareCo/executor/pull/1545) [`df62bb3`](https://github.com/UsefulSoftwareCo/executor/commit/df62bb3c8753edf2db32cb45961cf1723114ea2d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: reconnecting an OAuth connection now refreshes its health status in place — no page reload needed** | ||
| 60 | |||
| 61 | Completing a reconnect previously left the stale "Expired" verdict on the connection row (and the integrations-list summary) until a hard refresh. Re-minting now clears the persisted verdict, and the UI re-probes as soon as the refreshed connection arrives. | ||
| 62 | |||
| 63 | - [#1534](https://github.com/UsefulSoftwareCo/executor/pull/1534) [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Report the real product surface and version in the integrations.sh registry user-agent. The daemon previously sent `local` with a version frozen at 1.4.4; it now reports `cli` or `desktop` (matching analytics surfaces) and `@executor-js/local` is versioned with the release train. | ||
| 64 | |||
| 65 | - Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a), [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336)]: | ||
| 66 | - @executor-js/sdk@1.5.40 | ||
| 67 | - @executor-js/local@1.5.40 | ||
| 68 | - @executor-js/api@1.4.60 | ||
| 69 | - @executor-js/runtime-quickjs@1.5.40 | ||
| 70 | |||
| 71 | ## 1.5.39 | ||
| 72 | |||
| 73 | ### Patch Changes | ||
| 74 | |||
| 75 | - Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: | ||
| 76 | - @executor-js/sdk@1.5.39 | ||
| 77 | - @executor-js/local@1.4.4 | ||
| 78 | - @executor-js/api@1.4.59 | ||
| 79 | - @executor-js/runtime-quickjs@1.5.39 | ||
| 80 | |||
| 81 | ## 1.5.38 | ||
| 82 | |||
| 83 | ### Patch Changes | ||
| 84 | |||
| 85 | - [#1417](https://github.com/UsefulSoftwareCo/executor/pull/1417) [`046d67d`](https://github.com/UsefulSoftwareCo/executor/commit/046d67d75c3a8bc4cf0ab9dc4e723bc26ff130a3) Thanks [@morluto](https://github.com/morluto)! - Show policy and OAuth app removal failures in the UI, and keep success-only state unchanged when those writes fail. | ||
| 86 | |||
| 87 | - [#1511](https://github.com/UsefulSoftwareCo/executor/pull/1511) [`7eb795d`](https://github.com/UsefulSoftwareCo/executor/commit/7eb795dda19c6177ad3bd590005eca1e326f760c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `execute` scripts that both `emit()` output and `return` a value no longer lose the returned value in MCP clients that ignore `structuredContent` — the return value is now appended to the tool-result content after the emitted items** | ||
| 88 | |||
| 89 | - [#1418](https://github.com/UsefulSoftwareCo/executor/pull/1418) [`d3610e3`](https://github.com/UsefulSoftwareCo/executor/commit/d3610e386324891ccfde111e1ff519ec9218d30f) Thanks [@morluto](https://github.com/morluto)! - Return an execution error when a Deno subprocess closes stdin instead of emitting an unhandled write failure. | ||
| 90 | |||
| 91 | - [#1507](https://github.com/UsefulSoftwareCo/executor/pull/1507) [`541549a`](https://github.com/UsefulSoftwareCo/executor/commit/541549a5dd8806f45b1a01ea6f4fa18ac41f53b1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: OAuth refresh rejections with non-spec error bodies (e.g. Datadog) now surface as expired connections with a reconnect path, and definitively dead refresh tokens are no longer retried against the authorization server** | ||
| 92 | |||
| 93 | - [#1517](https://github.com/UsefulSoftwareCo/executor/pull/1517) [`59a6640`](https://github.com/UsefulSoftwareCo/executor/commit/59a6640e575c740d22e7804cdb67cd76efe8332b) Thanks [@kitze](https://github.com/kitze)! - **Fix: OpenAPI query parameters that use form-style exploded objects now serialize each object field as a query parameter.** | ||
| 94 | |||
| 95 | - [#1416](https://github.com/UsefulSoftwareCo/executor/pull/1416) [`f1b617c`](https://github.com/UsefulSoftwareCo/executor/commit/f1b617ce82475d4fe35be7a98c6bf9f468dbbd60) Thanks [@morluto](https://github.com/morluto)! - Prevent provider service migration row loss caused by generated ID conflicts. | ||
| 96 | |||
| 97 | - [#1420](https://github.com/UsefulSoftwareCo/executor/pull/1420) [`8c71744`](https://github.com/UsefulSoftwareCo/executor/commit/8c7174452fe05f32815950ed06f38516883a7c8f) Thanks [@morluto](https://github.com/morluto)! - Abort GraphQL tool calls that exceed the configured invocation timeout instead of waiting indefinitely for an upstream response. | ||
| 98 | |||
| 99 | - Updated dependencies [[`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]: | ||
| 100 | - @executor-js/sdk@1.5.38 | ||
| 101 | - @executor-js/local@1.4.4 | ||
| 102 | - @executor-js/api@1.4.58 | ||
| 103 | - @executor-js/runtime-quickjs@1.5.38 | ||
| 104 | |||
| 105 | ## 1.5.37 | ||
| 106 | |||
| 107 | ### Patch Changes | ||
| 108 | |||
| 109 | - [#1506](https://github.com/UsefulSoftwareCo/executor/pull/1506) [`c05a1cf`](https://github.com/UsefulSoftwareCo/executor/commit/c05a1cfa629b7f28a7d870c584998cb9cbbaf303) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Artifacts are now on by default for MCP connections.** A plain endpoint URL serves the full artifact surface — the artifact tools, the app shell resource, and the artifact skills. Connections that don't want it opt out with `?artifacts=false` (or `--no-artifacts` on the stdio CLI); `?artifacts=true` remains accepted as the explicit default. Previously the surface required a `?artifacts=true` opt-in. | ||
| 110 | |||
| 111 | - [#1498](https://github.com/UsefulSoftwareCo/executor/pull/1498) [`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add anonymous product analytics to the local daemon (CLI + desktop) and self-host: execution counts split by MCP/API plane, toolkit usage, integration add/remove, and artifact usage (created/viewed/updated/deleted, attributed to agent tools vs the console UI), filed under a persisted per-install anonymous id. Opt out with DO_NOT_TRACK or EXECUTOR_DISABLE_ANALYTICS. | ||
| 112 | |||
| 113 | - [#1500](https://github.com/UsefulSoftwareCo/executor/pull/1500) [`5eb2ca3`](https://github.com/UsefulSoftwareCo/executor/commit/5eb2ca36f93d2dae6eb8b3506c5de04ca141bb20) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the artifact migration no longer narrows `definition.name` to varchar(255), which failed on existing long definition names** | ||
| 114 | |||
| 115 | - [#1472](https://github.com/UsefulSoftwareCo/executor/pull/1472) [`1178e3b`](https://github.com/UsefulSoftwareCo/executor/commit/1178e3b31cd62cd2c05d6504d1586c2d8f018692) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title. | ||
| 116 | |||
| 117 | - [#1505](https://github.com/UsefulSoftwareCo/executor/pull/1505) [`9bd4a5b`](https://github.com/UsefulSoftwareCo/executor/commit/9bd4a5b6063bd98f5ae8070baf0dd3ee3e110d68) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Artifact tool results now include the web deep link beside the inline widget payload, not just on the no-apps fallback. Clients can lose a rendered widget in ways the server never sees — a reopened transcript that skips the resource re-read shows raw JSON — and the URL in the result is the model's way to point the user back at the artifact. | ||
| 118 | |||
| 119 | - [#1483](https://github.com/UsefulSoftwareCo/executor/pull/1483) [`54df2e3`](https://github.com/UsefulSoftwareCo/executor/commit/54df2e3e99509008759269b27484ee6581ce8827) Thanks [@davidwrossiter](https://github.com/davidwrossiter)! - **Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics** | ||
| 120 | |||
| 121 | - [#1499](https://github.com/UsefulSoftwareCo/executor/pull/1499) [`010ea98`](https://github.com/UsefulSoftwareCo/executor/commit/010ea98e520643731b07e68e21119f12b8ef1505) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the add-connection wizard no longer wipes a pasted credential when the key check saves a health check mid-flow** | ||
| 122 | |||
| 123 | - [#1503](https://github.com/UsefulSoftwareCo/executor/pull/1503) [`a7c4689`](https://github.com/UsefulSoftwareCo/executor/commit/a7c468944837cfe097f03f69d612bde31903f284) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP responses larger than Durable Object storage's 128 KiB per-value cap hung the client instead of being delivered** | ||
| 124 | |||
| 125 | The DO transport persisted every outbound message for reconnect replay before writing the live SSE frame, and `storage.put` of an oversize value throws — so a large response (the `ui://executor/shell.html` resource is ~5 MB) was neither stored nor sent, and the client waited on keepalives forever. The transport now delivers the live frame first and treats persistence as best-effort: an oversize message skips the event store with a logged warning and arrives without a replay id, which only costs replayability if the connection drops mid-delivery. | ||
| 126 | |||
| 127 | - [#1502](https://github.com/UsefulSoftwareCo/executor/pull/1502) [`25270b1`](https://github.com/UsefulSoftwareCo/executor/commit/25270b1f1f091778ba6584e41b041092fc1bdd00) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading** | ||
| 128 | |||
| 129 | The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted. | ||
| 130 | |||
| 131 | - Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: | ||
| 132 | - @executor-js/sdk@1.5.37 | ||
| 133 | - @executor-js/api@1.4.57 | ||
| 134 | - @executor-js/local@1.4.4 | ||
| 135 | - @executor-js/runtime-quickjs@1.5.37 | ||
| 136 | |||
| 137 | ## 1.5.36 | ||
| 138 | |||
| 139 | ### Patch Changes | ||
| 140 | |||
| 141 | - [#1478](https://github.com/UsefulSoftwareCo/executor/pull/1478) [`8ecbfd6`](https://github.com/UsefulSoftwareCo/executor/commit/8ecbfd65f2c1393c75661c792723961877866cc5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Store minted OAuth tokens in the durable file secret store (`auth.json` under `EXECUTOR_DATA_DIR`) instead of the system keychain. On sandbox/headless hosts the keychain can be an in-memory keyring that a stop/recreate wipes, leaving OAuth connections expired with "Stored refresh token could not be resolved." Existing keychain-backed connections migrate with one clean reconnect. | ||
| 142 | |||
| 143 | - [#1462](https://github.com/UsefulSoftwareCo/executor/pull/1462) [`5a70675`](https://github.com/UsefulSoftwareCo/executor/commit/5a706756c66e53c9a929e9a8c30e57166b8d121b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: org OAuth connections on self-host worked only for whoever ran the consent** | ||
| 144 | |||
| 145 | The encrypted-secrets credential provider (the writable provider on the self-hosted and Cloudflare hosts) filed token rows under the _acting user's_ private partition instead of the credential's own owner. An org-owned OAuth connection whose consent completed in one member's browser session therefore resolved only for that member — every other principal failed with `oauth_connection_missing`, while the UI showed the connection healthy. The provider now partitions by the owner embedded in the item id (`oauth:org:…` → org-shared), matching the WorkOS Vault provider, and a boot-time data migration re-files rows already written wrong. The encrypted value itself was never affected. | ||
| 146 | |||
| 147 | - [#1459](https://github.com/UsefulSoftwareCo/executor/pull/1459) [`fc1e589`](https://github.com/UsefulSoftwareCo/executor/commit/fc1e589613a14750c2ca8c34838a71c758544c8d) Thanks [@wan0net](https://github.com/wan0net)! - Preserve `elicitation_mode=native` when creating self-hosted MCP sessions. | ||
| 148 | |||
| 149 | - [#1475](https://github.com/UsefulSoftwareCo/executor/pull/1475) [`77b0821`](https://github.com/UsefulSoftwareCo/executor/commit/77b0821ff9bddd6fb419d81a18b9e1af804fdb55) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Refresh OAuth tokens when the upstream rejects them with HTTP 401, not only when the stored expiry says they are due. Connections whose authorization server omits `expires_in` can now recover without a manual reconnect, and the refresh path is traced. | ||
| 150 | |||
| 151 | - [#1476](https://github.com/UsefulSoftwareCo/executor/pull/1476) [`167d899`](https://github.com/UsefulSoftwareCo/executor/commit/167d899162794064eeac0a755697c2c943f1b9ac) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Remove the custom apps plugin. Git and local-directory app sources are no | ||
| 152 | longer supported. The packed binary still ships the workerd and worker-bundler | ||
| 153 | sidecars. | ||
| 154 | - Updated dependencies []: | ||
| 155 | - @executor-js/sdk@1.5.36 | ||
| 156 | - @executor-js/runtime-quickjs@1.5.36 | ||
| 157 | - @executor-js/local@1.4.4 | ||
| 158 | - @executor-js/api@1.4.56 | ||
| 159 | |||
| 160 | ## 1.5.35 | ||
| 161 | |||
| 162 | ### Patch Changes | ||
| 163 | |||
| 164 | - Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]: | ||
| 165 | - @executor-js/sdk@1.5.35 | ||
| 166 | - @executor-js/runtime-quickjs@1.5.35 | ||
| 167 | - @executor-js/local@1.4.4 | ||
| 168 | - @executor-js/api@1.4.55 | ||
| 169 | |||
| 170 | ## 1.5.34 | ||
| 171 | |||
| 172 | ### Patch Changes | ||
| 173 | |||
| 174 | - Updated dependencies [[`e2712db`](https://github.com/UsefulSoftwareCo/executor/commit/e2712dbff98145c5c340832ffbdcb21113b9dd78), [`7207347`](https://github.com/UsefulSoftwareCo/executor/commit/720734756a70b1b4f1564bdf82dc4118e5de2b76), [`0c4e9b4`](https://github.com/UsefulSoftwareCo/executor/commit/0c4e9b49fecb35ad71c92a464c3ea01131ff9d6f)]: | ||
| 175 | - @executor-js/sdk@1.5.34 | ||
| 176 | - @executor-js/local@1.4.4 | ||
| 177 | - @executor-js/api@1.4.54 | ||
| 178 | - @executor-js/runtime-quickjs@1.5.34 | ||
| 179 | |||
| 180 | ## 1.5.33 | ||
| 181 | |||
| 182 | ### Patch Changes | ||
| 183 | |||
| 184 | - [#1404](https://github.com/UsefulSoftwareCo/executor/pull/1404) [`5e0dd15`](https://github.com/UsefulSoftwareCo/executor/commit/5e0dd15291daaedf10f6eb8e03c5afdca8787764) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - The provider service split boot migration now skips an org whose Google or Microsoft integration cannot be migrated (for example a config without a stored specHash) instead of failing the whole migration and blocking server startup. A daemon that does fail during boot now exits with the underlying error message instead of hanging with a generic "Unknown error". | ||
| 185 | |||
| 186 | - Updated dependencies []: | ||
| 187 | - @executor-js/local@1.4.4 | ||
| 188 | - @executor-js/sdk@1.5.33 | ||
| 189 | - @executor-js/runtime-quickjs@1.5.33 | ||
| 190 | - @executor-js/api@1.4.53 | ||
| 191 | |||
| 192 | ## 1.5.32 | ||
| 193 | |||
| 194 | ### Patch Changes | ||
| 195 | |||
| 196 | - [#1395](https://github.com/UsefulSoftwareCo/executor/pull/1395) [`d90d8be`](https://github.com/UsefulSoftwareCo/executor/commit/d90d8be38fa26d3a6b8a5ac648af815191f537bb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Policy create now defaults a new rule's position below any more-specific existing rule on the server, so a broad rule written without an explicit position (stale UI, API, agent tool) cannot shadow an existing narrower rule. | ||
| 197 | |||
| 198 | - [#1394](https://github.com/UsefulSoftwareCo/executor/pull/1394) [`1ca5111`](https://github.com/UsefulSoftwareCo/executor/commit/1ca511101fad057d129db9941b5c9bce963baf0a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Ship the platform workerd binary in the self-host Docker runtime; without it custom app tools failed to sync or invoke with "workerd is unavailable on this platform". | ||
| 199 | |||
| 200 | - Updated dependencies []: | ||
| 201 | - @executor-js/sdk@1.5.32 | ||
| 202 | - @executor-js/runtime-quickjs@1.5.32 | ||
| 203 | - @executor-js/local@1.4.4 | ||
| 204 | - @executor-js/api@1.4.52 | ||
| 205 | |||
| 206 | ## 1.5.31 | ||
| 207 | |||
| 208 | ### Patch Changes | ||
| 209 | |||
| 210 | - [#1390](https://github.com/UsefulSoftwareCo/executor/pull/1390) [`d95e63c`](https://github.com/UsefulSoftwareCo/executor/commit/d95e63cc33ade4ce0996678cfa51a5e4c784a9ea) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Ship @cloudflare/worker-bundler in the self-host Docker runtime so the server starts; it was resolved at runtime since the dynamic Worker bundler change but never copied into the image. | ||
| 211 | |||
| 212 | - Updated dependencies []: | ||
| 213 | - @executor-js/local@1.4.4 | ||
| 214 | - @executor-js/sdk@1.5.31 | ||
| 215 | - @executor-js/runtime-quickjs@1.5.31 | ||
| 216 | - @executor-js/api@1.4.51 | ||
| 217 | |||
| 218 | ## 1.5.30 | ||
| 219 | |||
| 220 | ### Patch Changes | ||
| 221 | |||
| 222 | - [#1371](https://github.com/UsefulSoftwareCo/executor/pull/1371) [`262fc3e`](https://github.com/UsefulSoftwareCo/executor/commit/262fc3edcad31a53bd8aecacf8fe784b276fb745) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain 401s from a hosted server as a sign-in problem with the exact `executor login` command to run, instead of surfacing a raw decode error. `executor login` now defaults to https://executor.sh when no server is specified, and profile plumbing stays out of messages unless you address servers by name. | ||
| 223 | |||
| 224 | - [#1375](https://github.com/UsefulSoftwareCo/executor/pull/1375) [`0f81165`](https://github.com/UsefulSoftwareCo/executor/commit/0f81165138c1c987f13ff9839e46539f80b229b0) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Run cloud custom tool bundling in a dynamically loaded Worker so dependency installation and bundling do not share the serving request isolate. | ||
| 225 | |||
| 226 | - [#1349](https://github.com/UsefulSoftwareCo/executor/pull/1349) [`a7e3091`](https://github.com/UsefulSoftwareCo/executor/commit/a7e3091a94fbdf032ef134989ceaba4f0b1b3231) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Back-stop code execution with a host-side timeout so a wedged sandbox delivers a descriptive error instead of hanging silently. | ||
| 227 | |||
| 228 | - [#1351](https://github.com/UsefulSoftwareCo/executor/pull/1351) [`93b000a`](https://github.com/UsefulSoftwareCo/executor/commit/93b000a4ba24317f5a1f08fd8d7f72457d115f06) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call. | ||
| 229 | |||
| 230 | - [#1345](https://github.com/UsefulSoftwareCo/executor/pull/1345) [`c46730b`](https://github.com/UsefulSoftwareCo/executor/commit/c46730b5d48cc62dae1abdbe32136f3c229d79f6) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Preserve MCP tool results across dropped streamable HTTP SSE connections. | ||
| 231 | |||
| 232 | - [#1357](https://github.com/UsefulSoftwareCo/executor/pull/1357) [`4c319ee`](https://github.com/UsefulSoftwareCo/executor/commit/4c319ee2e4a8d062402a80419dd3e1d829908ad8) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Throw a guidance error when sandbox code enumerates the `tools` proxy (`Object.keys`, spread, `for...in`) instead of returning an empty list, pointing agents at `tools.search()`. | ||
| 233 | |||
| 234 | - [#1352](https://github.com/UsefulSoftwareCo/executor/pull/1352) [`8bdf315`](https://github.com/UsefulSoftwareCo/executor/commit/8bdf31550f702e6bce6c3460bb9d26fcce925d7b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Treat a transient WorkOS outage during the MCP live-membership check as a retryable 503 instead of a Forbidden that destroys the session. | ||
| 235 | |||
| 236 | - Updated dependencies []: | ||
| 237 | - @executor-js/sdk@1.5.30 | ||
| 238 | - @executor-js/runtime-quickjs@1.5.30 | ||
| 239 | - @executor-js/local@1.4.4 | ||
| 240 | - @executor-js/api@1.4.50 | ||
| 241 | |||
| 3 | ## 1.5.29 | 242 | ## 1.5.29 |
| 4 | 243 | ||
| 5 | ### Patch Changes | 244 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "executor", | 2 | "name": "executor", |
| 3 | "version": "1.5.29", | 3 | "version": "1.6.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "bin": { | 5 | "bin": { |
| 6 | "executor": "./bin/executor.ts" | 6 | "executor": "./bin/executor.ts" |
| @@ -37,8 +37,11 @@ · 30 unmodified lines | |||
| 37 | "quickjs-emscripten": "catalog:" | 37 | "quickjs-emscripten": "catalog:" |
| 38 | }, | 38 | }, |
| 39 | "devDependencies": { | 39 | "devDependencies": { |
| 40 | "@cloudflare/worker-bundler": "0.2.1", | ||
| 40 | "@effect/vitest": "catalog:", | 41 | "@effect/vitest": "catalog:", |
| 42 | "@types/node": "catalog:", | ||
| 41 | "bun-types": "catalog:", | 43 | "bun-types": "catalog:", |
| 44 | "esbuild": "^0.27.3", | ||
| 42 | "typescript": "catalog:", | 45 | "typescript": "catalog:", |
| 43 | "vitest": "catalog:" | 46 | "vitest": "catalog:" |
| 44 | } | 47 | } |
| @@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line | |||
| 2 | import { existsSync } from "node:fs"; | 2 | import { existsSync } from "node:fs"; |
| 3 | import { createRequire } from "node:module"; | 3 | import { createRequire } from "node:module"; |
| 4 | import { resolve, join, dirname } from "node:path"; | 4 | import { resolve, join, dirname } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 6 | import { parseArgs } from "node:util"; | 6 | import { parseArgs } from "node:util"; |
| 7 | import { $ } from "bun"; | 7 | import { $ } from "bun"; |
| 8 | import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | ||
| 8 | 9 | ||
| 9 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); | 10 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); |
| 10 | const cliRoot = resolve(repoRoot, "apps/cli"); | 11 | const cliRoot = resolve(repoRoot, "apps/cli"); |
| 11 | const webRoot = resolve(repoRoot, "apps/local"); | 12 | const webRoot = resolve(repoRoot, "apps/local"); |
| 12 | const distDir = resolve(cliRoot, "dist"); | 13 | const distDir = resolve(cliRoot, "dist"); |
| 13 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; | 14 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; |
| 15 | const WORKERD_VERSION = "1.20260708.1"; | ||
| 14 | 16 | ||
| 15 | const resolveQuickJsWasmPath = (): string => { | 17 | const resolveQuickJsWasmPath = (): string => { |
| 16 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); | 18 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); |
| @@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines | |||
| 23 | return wasmPath; | 25 | return wasmPath; |
| 24 | }; | 26 | }; |
| 25 | 27 | ||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // MCP-Apps shell (mcp-app.html) | ||
| 30 | // | ||
| 31 | // The MCP host serves the shell as the `ui://executor/shell.html` resource, and | ||
| 32 | // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime | ||
| 33 | // `fs.readFile` is invisible to `bun build --compile`, so without this the | ||
| 34 | // packaged binary would fail every artifact resource read. Build it if missing | ||
| 35 | // and copy it next to the executable, where the loader finds it via | ||
| 36 | // `process.execPath` — the same colocation trick used for `libsql.node` / | ||
| 37 | // `emscripten-module.wasm`. | ||
| 38 | // --------------------------------------------------------------------------- | ||
| 39 | |||
| 40 | const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); | ||
| 41 | const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); | ||
| 42 | |||
| 43 | const ensureMcpAppsShell = async (): Promise<string> => { | ||
| 44 | if (!existsSync(mcpAppsShellHtml)) { | ||
| 45 | console.log("Building MCP-Apps shell (mcp-app.html)..."); | ||
| 46 | await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); | ||
| 47 | } | ||
| 48 | if (!existsSync(mcpAppsShellHtml)) { | ||
| 49 | throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); | ||
| 50 | } | ||
| 51 | return mcpAppsShellHtml; | ||
| 52 | }; | ||
| 53 | |||
| 26 | const resolveOnePasswordCoreWasmPath = (): string => { | 54 | const resolveOnePasswordCoreWasmPath = (): string => { |
| 27 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); | 55 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); |
| 28 | const sdkPkg = req.resolve("@1password/sdk/package.json"); | 56 | const sdkPkg = req.resolve("@1password/sdk/package.json"); |
| @@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines | |||
| 33 | return wasmPath; | 61 | return wasmPath; |
| 34 | }; | 62 | }; |
| 35 | 63 | ||
| 64 | const resolveWorkerBundlerDistPath = (): string => { | ||
| 65 | const req = createRequire(join(repoRoot, "apps/cli/package.json")); | ||
| 66 | const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); | ||
| 67 | const distPath = join(dirname(pkgJson), "dist"); | ||
| 68 | if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { | ||
| 69 | throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`); | ||
| 70 | } | ||
| 71 | return distPath; | ||
| 72 | }; | ||
| 73 | |||
| 74 | const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => { | ||
| 75 | const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js"); | ||
| 76 | if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`); | ||
| 77 | const { build } = await import(pathToFileURL(esbuildPath).href); | ||
| 78 | const result = await build({ | ||
| 79 | entryPoints: [join(distPath, "index.js")], | ||
| 80 | bundle: true, | ||
| 81 | format: "esm", | ||
| 82 | platform: "browser", | ||
| 83 | external: ["./esbuild.wasm"], | ||
| 84 | logLevel: "silent", | ||
| 85 | write: false, | ||
| 86 | }); | ||
| 87 | const source = result.outputFiles[0]?.text; | ||
| 88 | if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); | ||
| 89 | return source; | ||
| 90 | }; | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Verify the staged worker-bundler beside a freshly compiled binary. | ||
| 94 | * | ||
| 95 | * The compiled binary cannot resolve `@cloudflare/worker-bundler` by name — | ||
| 96 | * bunfs has no node_modules, so a bare specifier throws ResolveMessage at | ||
| 97 | * runtime. The colocated copy IS the delivery mechanism, and until now nothing | ||
| 98 | * checked it after the copy: a partial staging shipped a binary that only fails | ||
| 99 | * on the user's machine, at startup. Assert it here, where a failure costs a | ||
| 100 | * red build instead of a crash loop. | ||
| 101 | * | ||
| 102 | * Checks match the depth of the asset assertions elsewhere in the repo: | ||
| 103 | * presence of every file the runtime contract names, a size floor on the packed | ||
| 104 | * entry, and the `\0asm` magic on the wasm so a truncated or LFS-pointer copy | ||
| 105 | * cannot pass as real. | ||
| 106 | */ | ||
| 107 | const assertPackedWorkerBundler = async (binDir: string, targetName: string): Promise<void> => { | ||
| 108 | const stagedDir = join(binDir, WORKER_BUNDLER_DIRNAME); | ||
| 109 | |||
| 110 | const missing = missingWorkerBundlerFiles(stagedDir, existsSync); | ||
| 111 | if (missing.length > 0) { | ||
| 112 | throw new Error( | ||
| 113 | `Packed @cloudflare/worker-bundler is incomplete for ${targetName}: missing ${missing.join(", ")} under ${stagedDir}. ` + | ||
| 114 | `The compiled binary resolves this package from disk, not from bunfs, so shipping it partial means a runtime failure on the user's machine.`, | ||
| 115 | ); | ||
| 116 | } | ||
| 117 | |||
| 118 | const bundledEntry = join(stagedDir, "dist", "index.bundled.js"); | ||
| 119 | const bundledSize = (await Bun.file(bundledEntry).arrayBuffer()).byteLength; | ||
| 120 | if (bundledSize < 100_000) { | ||
| 121 | throw new Error( | ||
| 122 | `Packed worker-bundler entry for ${targetName} is implausibly small (${bundledSize} bytes at ${bundledEntry}). Expected the esbuild-packed bundle.`, | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | const wasmPath = join(stagedDir, "dist", "esbuild.wasm"); | ||
| 127 | const wasmHead = new Uint8Array((await Bun.file(wasmPath).arrayBuffer()).slice(0, 4)); | ||
| 128 | if ( | ||
| 129 | wasmHead[0] !== 0x00 || | ||
| 130 | wasmHead[1] !== 0x61 || | ||
| 131 | wasmHead[2] !== 0x73 || | ||
| 132 | wasmHead[3] !== 0x6d | ||
| 133 | ) { | ||
| 134 | throw new Error( | ||
| 135 | `Staged esbuild.wasm for ${targetName} is not a WebAssembly module (bad magic at ${wasmPath}).`, | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 36 | // --------------------------------------------------------------------------- | 140 | // --------------------------------------------------------------------------- |
| 37 | // Metadata | 141 | // Metadata |
| 38 | // --------------------------------------------------------------------------- | 142 | // --------------------------------------------------------------------------- |
| @@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines | |||
| 119 | }; | 223 | }; |
| 120 | 224 | ||
| 121 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); | 225 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); |
| 226 | const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd"); | ||
| 122 | 227 | ||
| 123 | const isCurrentPlatform = (t: Target) => | 228 | const isCurrentPlatform = (t: Target) => |
| 124 | t.os === process.platform && t.arch === process.arch && !t.abi; | 229 | t.os === process.platform && t.arch === process.arch && !t.abi; |
| @@ -211,6 +316,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines | |||
| 211 | } | 316 | } |
| 212 | }; | 317 | }; |
| 213 | 318 | ||
| 319 | const resolveWorkerdBinary = (t: Target): string | null => { | ||
| 320 | const platformMap: Record<string, string> = { | ||
| 321 | "darwin-arm64": "@cloudflare/workerd-darwin-arm64", | ||
| 322 | "darwin-x64": "@cloudflare/workerd-darwin-64", | ||
| 323 | "linux-arm64": "@cloudflare/workerd-linux-arm64", | ||
| 324 | "linux-x64": "@cloudflare/workerd-linux-64", | ||
| 325 | "win32-x64": "@cloudflare/workerd-windows-64", | ||
| 326 | }; | ||
| 327 | const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); | ||
| 328 | const pkg = platformMap[key]; | ||
| 329 | if (!pkg) return null; | ||
| 330 | const binary = workerdBinaryName(t); | ||
| 331 | try { | ||
| 332 | const req = createRequire( | ||
| 333 | join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"), | ||
| 334 | ); | ||
| 335 | const pkgJson = req.resolve(`${pkg}/package.json`); | ||
| 336 | for (const candidate of [ | ||
| 337 | join(dirname(pkgJson), "bin", binary), | ||
| 338 | join(dirname(pkgJson), binary), | ||
| 339 | ]) { | ||
| 340 | if (existsSync(candidate)) return candidate; | ||
| 341 | } | ||
| 342 | } catch { | ||
| 343 | const packageRoot = join( | ||
| 344 | repoRoot, | ||
| 345 | `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`, | ||
| 346 | ); | ||
| 347 | for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) { | ||
| 348 | if (existsSync(candidate)) return candidate; | ||
| 349 | } | ||
| 350 | } | ||
| 351 | return null; | ||
| 352 | }; | ||
| 353 | |||
| 214 | // --------------------------------------------------------------------------- | 354 | // --------------------------------------------------------------------------- |
| 215 | // Build mode | 355 | // Build mode |
| 216 | // --------------------------------------------------------------------------- | 356 | // --------------------------------------------------------------------------- |
| @@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines | |||
| 332 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); | 472 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); |
| 333 | 473 | ||
| 334 | const quickJsWasmPath = resolveQuickJsWasmPath(); | 474 | const quickJsWasmPath = resolveQuickJsWasmPath(); |
| 475 | const mcpAppsShellPath = await ensureMcpAppsShell(); | ||
| 335 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); | 476 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); |
| 477 | const workerBundlerDistPath = resolveWorkerBundlerDistPath(); | ||
| 478 | const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); | ||
| 336 | 479 | ||
| 337 | try { | 480 | try { |
| 338 | for (const target of targets) { | 481 | for (const target of targets) { |
| @@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines | |||
| 355 | // Copy QuickJS WASM next to binary — loaded at runtime by the server | 498 | // Copy QuickJS WASM next to binary — loaded at runtime by the server |
| 356 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); | 499 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); |
| 357 | 500 | ||
| 501 | // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, | ||
| 502 | // read from execDir at runtime by @executor-js/mcp-apps-shell. | ||
| 503 | await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); | ||
| 504 | |||
| 358 | // Copy 1Password's sdk-core WASM next to the binary. The patched | 505 | // Copy 1Password's sdk-core WASM next to the binary. The patched |
| 359 | // sdk-core loader falls back to this sibling file when bun --compile | 506 | // sdk-core loader falls back to this sibling file when bun --compile |
| 360 | // bakes a build-machine node_modules path into __dirname. | 507 | // bakes a build-machine node_modules path into __dirname. |
| @@ -375,6 +522,21 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 14 unmodified lines | |||
| 375 | await cp(libsqlNative, join(binDir, "libsql.node")); | 522 | await cp(libsqlNative, join(binDir, "libsql.node")); |
| 376 | } | 523 | } |
| 377 | 524 | ||
| 525 | const workerdBinary = resolveWorkerdBinary(target); | ||
| 526 | if (workerdBinary && existsSync(workerdBinary)) { | ||
| 527 | await cp(workerdBinary, join(binDir, workerdBinaryName(target))); | ||
| 528 | if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755); | ||
| 529 | } | ||
| 530 | |||
| 531 | await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), { | ||
| 532 | recursive: true, | ||
| 533 | }); | ||
| 534 | await writeFile( | ||
| 535 | join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"), | ||
| 536 | packedWorkerBundlerSource, | ||
| 537 | ); | ||
| 538 | await assertPackedWorkerBundler(binDir, name); | ||
| 539 | |||
| 378 | // Smoke test on current platform | 540 | // Smoke test on current platform |
| 379 | if (isCurrentPlatform(target)) { | 541 | if (isCurrentPlatform(target)) { |
| 380 | const bin = join(binDir, binaryName(target)); | 542 | const bin = join(binDir, binaryName(target)); |
| @@ -1,6 +1,44 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { browserOpenCommand } from "./device-login"; | 3 | import { |
| 4 | browserOpenCommand, | ||
| 5 | discoverCliLogin, | ||
| 6 | refreshDeviceTokens, | ||
| 7 | requestDeviceCode, | ||
| 8 | type CliLoginDiscovery, | ||
| 9 | } from "./device-login"; | ||
| 10 | |||
| 11 | const originalFetch = globalThis.fetch; | ||
| 12 | |||
| 13 | interface FetchCall { | ||
| 14 | readonly url: string; | ||
| 15 | readonly headers: Record<string, string>; | ||
| 16 | } | ||
| 17 | |||
| 18 | const responseJson = (body: Record<string, unknown>, status = 200): Response => | ||
| 19 | new Response(JSON.stringify(body), { | ||
| 20 | status, | ||
| 21 | headers: { "content-type": "application/json" }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => { | ||
| 25 | globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { | ||
| 26 | const url = | ||
| 27 | typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; | ||
| 28 | return Promise.resolve(handler(url, init)); | ||
| 29 | }) as typeof fetch; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => { | ||
| 33 | calls.push({ | ||
| 34 | url, | ||
| 35 | headers: Object.fromEntries(new Headers(init?.headers).entries()), | ||
| 36 | }); | ||
| 37 | }; | ||
| 38 | |||
| 39 | afterEach(() => { | ||
| 40 | globalThis.fetch = originalFetch; | ||
| 41 | }); | ||
| 4 | 42 | ||
| 5 | describe("browserOpenCommand", () => { | 43 | describe("browserOpenCommand", () => { |
| 6 | it("opens Windows browser URLs without cmd.exe", () => { | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | |||
| 32 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); | 70 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); |
| 33 | }); | 71 | }); |
| 34 | }); | 72 | }); |
| 73 | |||
| 74 | describe("device login headers", () => { | ||
| 75 | it("sends configured headers when discovering CLI login", async () => { | ||
| 76 | const calls: Array<FetchCall> = []; | ||
| 77 | installFetch((url, init) => { | ||
| 78 | recordCall(calls, url, init); | ||
| 79 | return responseJson({ | ||
| 80 | provider: "better-auth", | ||
| 81 | deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", | ||
| 82 | tokenEndpoint: "https://executor.example/api/auth/device/token", | ||
| 83 | clientId: "executor-cli", | ||
| 84 | requestFormat: "json", | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | const discovery = await discoverCliLogin("https://executor.example", { | ||
| 89 | headers: { "CF-Access-Client-Id": "client-id" }, | ||
| 90 | }); | ||
| 91 | |||
| 92 | expect(discovery.clientId).toBe("executor-cli"); | ||
| 93 | expect(calls).toHaveLength(1); | ||
| 94 | expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login"); | ||
| 95 | expect(calls[0]?.headers).toMatchObject({ | ||
| 96 | accept: "application/json", | ||
| 97 | "cf-access-client-id": "client-id", | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("sends configured headers only to same-origin device endpoints", async () => { | ||
| 102 | const calls: Array<FetchCall> = []; | ||
| 103 | installFetch((url, init) => { | ||
| 104 | recordCall(calls, url, init); | ||
| 105 | if (url.endsWith("/api/auth/device/code")) { | ||
| 106 | return responseJson({ | ||
| 107 | device_code: "device-code", | ||
| 108 | user_code: "USER-CODE", | ||
| 109 | verification_uri: "https://executor.example/device", | ||
| 110 | expires_in: 300, | ||
| 111 | interval: 5, | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | return responseJson({ | ||
| 115 | access_token: "access-token", | ||
| 116 | refresh_token: "refresh-token-2", | ||
| 117 | expires_in: 600, | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | const discovery: CliLoginDiscovery = { | ||
| 121 | provider: "better-auth", | ||
| 122 | deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", | ||
| 123 | tokenEndpoint: "https://accounts.example/oauth/token", | ||
| 124 | clientId: "executor-cli", | ||
| 125 | requestFormat: "form", | ||
| 126 | }; | ||
| 127 | const headers = { "CF-Access-Client-Id": "client-id" }; | ||
| 128 | |||
| 129 | await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers }); | ||
| 130 | await refreshDeviceTokens({ | ||
| 131 | tokenEndpoint: discovery.tokenEndpoint, | ||
| 132 | clientId: discovery.clientId, | ||
| 133 | refreshToken: "refresh-token", | ||
| 134 | serverOrigin: "https://executor.example", | ||
| 135 | headers, | ||
| 136 | }); | ||
| 137 | |||
| 138 | expect(calls).toHaveLength(2); | ||
| 139 | expect(calls[0]?.headers).toMatchObject({ | ||
| 140 | accept: "application/json", | ||
| 141 | "content-type": "application/x-www-form-urlencoded", | ||
| 142 | "cf-access-client-id": "client-id", | ||
| 143 | }); | ||
| 144 | expect(calls[1]?.headers).toMatchObject({ | ||
| 145 | accept: "application/json", | ||
| 146 | "content-type": "application/x-www-form-urlencoded", | ||
| 147 | }); | ||
| 148 | expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined(); | ||
| 149 | }); | ||
| 150 | }); | ||
| @@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines | |||
| 50 | readonly organizationId?: string; | 50 | readonly organizationId?: string; |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | export interface DeviceLoginHttpOptions { | ||
| 54 | readonly headers?: Readonly<Record<string, string>>; | ||
| 55 | readonly serverOrigin?: string; | ||
| 56 | } | ||
| 57 | |||
| 58 | export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions { | ||
| 59 | readonly now?: () => number; | ||
| 60 | } | ||
| 61 | |||
| 53 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | const DEFAULT_INTERVAL_SECONDS = 5; | 63 | const DEFAULT_INTERVAL_SECONDS = 5; |
| 55 | 64 | ||
| @@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines | |||
| 126 | string | 135 | string |
| 127 | >; | 136 | >; |
| 128 | 137 | ||
| 138 | const isSameOrigin = (url: string, origin: string): boolean => { | ||
| 139 | try { | ||
| 140 | return new URL(url).origin === new URL(origin).origin; | ||
| 141 | } catch { | ||
| 142 | return false; | ||
| 143 | } | ||
| 144 | }; | ||
| 145 | |||
| 146 | const headersForUrl = ( | ||
| 147 | url: string, | ||
| 148 | baseHeaders: Record<string, string>, | ||
| 149 | options: DeviceLoginHttpOptions = {}, | ||
| 150 | ): Record<string, string> => { | ||
| 151 | const configured = | ||
| 152 | options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin)) | ||
| 153 | ? options.headers | ||
| 154 | : {}; | ||
| 155 | return { ...configured, ...baseHeaders }; | ||
| 156 | }; | ||
| 157 | |||
| 129 | const post = async ( | 158 | const post = async ( |
| 130 | url: string, | 159 | url: string, |
| 131 | fields: Record<string, string | undefined>, | 160 | fields: Record<string, string | undefined>, |
| 132 | format: "form" | "json", | 161 | format: "form" | "json", |
| 162 | options: DeviceLoginHttpOptions = {}, | ||
| 133 | ) => | 163 | ) => |
| 134 | fetch(url, { | 164 | fetch(url, { |
| 135 | method: "POST", | 165 | method: "POST", |
| 136 | headers: { | 166 | headers: headersForUrl( |
| 137 | "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", | 167 | url, |
| 138 | accept: "application/json", | 168 | { |
| 139 | }, | 169 | "content-type": |
| 170 | format === "json" ? "application/json" : "application/x-www-form-urlencoded", | ||
| 171 | accept: "application/json", | ||
| 172 | }, | ||
| 173 | options, | ||
| 174 | ), | ||
| 140 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), | 175 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), |
| 141 | }); | 176 | }); |
| 142 | 177 | ||
| @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines | |||
| 150 | } | 185 | } |
| 151 | }; | 186 | }; |
| 152 | 187 | ||
| 153 | export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => { | 188 | export const discoverCliLogin = async ( |
| 189 | origin: string, | ||
| 190 | options: DeviceLoginHttpOptions = {}, | ||
| 191 | ): Promise<CliLoginDiscovery> => { | ||
| 154 | let response: Response; | 192 | let response: Response; |
| 193 | const url = cliLoginUrl(origin); | ||
| 155 | try { | 194 | try { |
| 156 | response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); | 195 | response = await fetch(url, { |
| 196 | headers: headersForUrl(url, { accept: "application/json" }, options), | ||
| 197 | }); | ||
| 157 | } catch (cause) { | 198 | } catch (cause) { |
| 158 | throw new DeviceLoginError( | 199 | throw new DeviceLoginError( |
| 159 | `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, | 200 | `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, |
| @@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines | |||
| 182 | }; | 223 | }; |
| 183 | }; | 224 | }; |
| 184 | 225 | ||
| 185 | export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => { | 226 | export const requestDeviceCode = async ( |
| 227 | discovery: CliLoginDiscovery, | ||
| 228 | options: DeviceLoginHttpOptions = {}, | ||
| 229 | ): Promise<DeviceCodeGrant> => { | ||
| 186 | const response = await post( | 230 | const response = await post( |
| 187 | discovery.deviceAuthorizationEndpoint, | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | { client_id: discovery.clientId, scope: discovery.scope }, | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | discovery.requestFormat, | 233 | discovery.requestFormat, |
| 234 | options, | ||
| 190 | ); | 235 | ); |
| 191 | const body = await readJson(response); | 236 | const body = await readJson(response); |
| 192 | if (!response.ok) { | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | |||
| 220 | export const pollForDeviceTokens = async ( | 265 | export const pollForDeviceTokens = async ( |
| 221 | discovery: CliLoginDiscovery, | 266 | discovery: CliLoginDiscovery, |
| 222 | grant: DeviceCodeGrant, | 267 | grant: DeviceCodeGrant, |
| 223 | options: { readonly now?: () => number } = {}, | 268 | options: PollForDeviceTokensOptions = {}, |
| 224 | ): Promise<DeviceTokens> => { | 269 | ): Promise<DeviceTokens> => { |
| 225 | const now = options.now ?? (() => Date.now()); | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | const deadline = now() + grant.expiresInSeconds * 1000; | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | |||
| 240 | client_id: discovery.clientId, | 285 | client_id: discovery.clientId, |
| 241 | }, | 286 | }, |
| 242 | discovery.requestFormat, | 287 | discovery.requestFormat, |
| 288 | options, | ||
| 243 | ); | 289 | ); |
| 244 | const body = await readJson(response); | 290 | const body = await readJson(response); |
| 245 | 291 | ||
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | |||
| 286 | readonly tokenEndpoint: string; | 332 | readonly tokenEndpoint: string; |
| 287 | readonly clientId: string; | 333 | readonly clientId: string; |
| 288 | readonly refreshToken: string; | 334 | readonly refreshToken: string; |
| 335 | readonly headers?: Readonly<Record<string, string>>; | ||
| 336 | readonly serverOrigin?: string; | ||
| 289 | }): Promise<DeviceTokens> => { | 337 | }): Promise<DeviceTokens> => { |
| 290 | const response = await post( | 338 | const response = await post( |
| 291 | input.tokenEndpoint, | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | |||
| 295 | client_id: input.clientId, | 343 | client_id: input.clientId, |
| 296 | }, | 344 | }, |
| 297 | "form", | 345 | "form", |
| 346 | input, | ||
| 298 | ); | 347 | ); |
| 299 | const body = await readJson(response); | 348 | const body = await readJson(response); |
| 300 | if (!response.ok) { | 349 | if (!response.ok) { |
| @@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines | |||
| 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; | 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; | 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 66 | import { HttpApiClient } from "effect/unstable/httpapi"; | 66 | import { HttpApiClient } from "effect/unstable/httpapi"; |
| 67 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 67 | import { |
| 68 | FetchHttpClient, | ||
| 69 | HttpClient, | ||
| 70 | HttpClientError, | ||
| 71 | HttpClientRequest, | ||
| 72 | } from "effect/unstable/http"; | ||
| 68 | import { FileSystem, Path as PlatformPath } from "effect"; | 73 | import { FileSystem, Path as PlatformPath } from "effect"; |
| 69 | import type { PlatformError } from "effect/PlatformError"; | 74 | import type { PlatformError } from "effect/PlatformError"; |
| 70 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| @@ -79,10 +84,13 @@ import { · 8 unmodified lines | |||
| 79 | getExecutorServerAuthorizationHeader, | 84 | getExecutorServerAuthorizationHeader, |
| 80 | normalizeExecutorServerConnection, | 85 | normalizeExecutorServerConnection, |
| 81 | normalizeExecutorServerOrigin, | 86 | normalizeExecutorServerOrigin, |
| 87 | resolveExecutorServerConfiguredHeaders, | ||
| 88 | resolveExecutorServerRequestHeaders, | ||
| 82 | type ExecutorLocalServerKind, | 89 | type ExecutorLocalServerKind, |
| 83 | type ExecutorLocalServerManifest, | 90 | type ExecutorLocalServerManifest, |
| 84 | type ExecutorServerConnection, | 91 | type ExecutorServerConnection, |
| 85 | type ExecutorServerConnectionInput, | 92 | type ExecutorServerConnectionInput, |
| 93 | type ExecutorServerHeaders, | ||
| 86 | } from "@executor-js/sdk/shared"; | 94 | } from "@executor-js/sdk/shared"; |
| 87 | import { | 95 | import { |
| 88 | decodeAccessTokenClaims, | 96 | decodeAccessTokenClaims, |
| @@ -131,7 +139,9 @@ import { · 42 unmodified lines | |||
| 131 | import { | 139 | import { |
| 132 | canAutoStartCliServerConnection, | 140 | canAutoStartCliServerConnection, |
| 133 | chooseCliServerConnectionWithActiveLocal, | 141 | chooseCliServerConnectionWithActiveLocal, |
| 142 | describeUnauthorizedCliServer, | ||
| 134 | parseCliExecutorServerConnection, | 143 | parseCliExecutorServerConnection, |
| 144 | profileNameFromConnectionKey, | ||
| 135 | type CliServerConnectionSource, | 145 | type CliServerConnectionSource, |
| 136 | withCliServerAuthFallback, | 146 | withCliServerAuthFallback, |
| 137 | } from "./server-connection"; | 147 | } from "./server-connection"; |
| @@ -162,7 +172,7 @@ import { · 24 unmodified lines | |||
| 162 | buildDescribeToolCode, | 172 | buildDescribeToolCode, |
| 163 | filterToolPathChildren, | 173 | filterToolPathChildren, |
| 164 | buildInvokeToolCode, | 174 | buildInvokeToolCode, |
| 165 | buildListSourcesCode, | 175 | buildListIntegrationsCode, |
| 166 | buildSearchToolsCode, | 176 | buildSearchToolsCode, |
| 167 | extractExecutionId, | 177 | extractExecutionId, |
| 168 | extractPausedInteraction, | 178 | extractPausedInteraction, |
| @@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines | |||
| 205 | const shutdown = () => resume(Effect.void); | 215 | const shutdown = () => resume(Effect.void); |
| 206 | process.once("SIGINT", shutdown); | 216 | process.once("SIGINT", shutdown); |
| 207 | process.once("SIGTERM", shutdown); | 217 | process.once("SIGTERM", shutdown); |
| 218 | process.once("SIGHUP", shutdown); | ||
| 208 | return Effect.sync(() => { | 219 | return Effect.sync(() => { |
| 209 | process.off("SIGINT", shutdown); | 220 | process.off("SIGINT", shutdown); |
| 210 | process.off("SIGTERM", shutdown); | 221 | process.off("SIGTERM", shutdown); |
| 222 | process.off("SIGHUP", shutdown); | ||
| 211 | }); | 223 | }); |
| 212 | }); | 224 | }); |
| 213 | 225 | ||
| @@ -743,12 +755,9 @@ const resolveRequestedExecutorServerConnection = ( · 529 unmodified lines | |||
| 743 | // login. The refreshed tokens are written back to the originating profile. | 755 | // login. The refreshed tokens are written back to the originating profile. |
| 744 | const OAUTH_REFRESH_SKEW_SECONDS = 60; | 756 | const OAUTH_REFRESH_SKEW_SECONDS = 60; |
| 745 | 757 | ||
| 746 | const profileNameFromKey = (key: string): string | null => | ||
| 747 | key.startsWith("profile:") ? key.slice("profile:".length) : null; | ||
| 748 | |||
| 749 | const refreshOAuthConnection = ( | 758 | const refreshOAuthConnection = ( |
| 750 | connection: ExecutorServerConnection, | 759 | connection: ExecutorServerConnection, |
| 751 | ): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | 760 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 752 | Effect.gen(function* () { | 761 | Effect.gen(function* () { |
| 753 | const auth = connection.auth; | 762 | const auth = connection.auth; |
| 754 | if (!auth || auth.kind !== "oauth") return connection; | 763 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | |||
| 759 | const { refreshToken, tokenEndpoint, clientId } = auth; | 768 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 760 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; | 769 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; |
| 761 | 770 | ||
| 771 | const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( | ||
| 772 | Effect.mapError(toError), | ||
| 773 | ); | ||
| 774 | |||
| 762 | const refreshed = yield* Effect.tryPromise({ | 775 | const refreshed = yield* Effect.tryPromise({ |
| 763 | try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), | 776 | try: () => |
| 777 | refreshDeviceTokens({ | ||
| 778 | tokenEndpoint, | ||
| 779 | clientId, | ||
| 780 | refreshToken, | ||
| 781 | serverOrigin: connection.origin, | ||
| 782 | headers, | ||
| 783 | }), | ||
| 764 | catch: toError, | 784 | catch: toError, |
| 765 | // On a failed refresh, keep the existing token and let the eventual 401 | 785 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 766 | // surface, better than blocking the command on a transient hiccup. | 786 | // surface, better than blocking the command on a transient hiccup. |
| @@ -780,7 +800,7 @@ const refreshOAuthConnection = ( · 13 unmodified lines | |||
| 780 | }, | 800 | }, |
| 781 | }); | 801 | }); |
| 782 | 802 | ||
| 783 | const profileName = profileNameFromKey(connection.key); | 803 | const profileName = profileNameFromConnectionKey(connection.key); |
| 784 | if (profileName) { | 804 | if (profileName) { |
| 785 | yield* upsertCliServerConnectionProfile({ | 805 | yield* upsertCliServerConnectionProfile({ |
| 786 | name: profileName, | 806 | name: profileName, |
| @@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines | |||
| 961 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => | 981 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 962 | Effect.gen(function* () { | 982 | Effect.gen(function* () { |
| 963 | const connection = yield* resolveExecutorServerConnection(input.target); | 983 | const connection = yield* resolveExecutorServerConnection(input.target); |
| 964 | const client = yield* makeApiClient(connection); | 984 | const client = yield* makeApiClient(connection, input.target); |
| 965 | const response = yield* client.executions.execute({ | 985 | const response = yield* client.executions.execute({ |
| 966 | payload: { | 986 | payload: { |
| 967 | code: input.code, | 987 | code: input.code, |
| @@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines | |||
| 1050 | // Typed API client | 1070 | // Typed API client |
| 1051 | // --------------------------------------------------------------------------- | 1071 | // --------------------------------------------------------------------------- |
| 1052 | 1072 | ||
| 1053 | const makeApiClient = (connection: ExecutorServerConnection) => { | 1073 | const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => |
| 1054 | const authorization = getExecutorServerAuthorizationHeader(connection); | 1074 | Effect.gen(function* () { |
| 1055 | return HttpApiClient.make(ExecutorApi, { | 1075 | const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( |
| 1056 | baseUrl: connection.apiBaseUrl, | 1076 | Effect.mapError(toError), |
| 1057 | ...(authorization | 1077 | ); |
| 1058 | ? { | 1078 | return yield* HttpApiClient.make(ExecutorApi, { |
| 1059 | transformClient: HttpClient.mapRequest((request) => | 1079 | baseUrl: connection.apiBaseUrl, |
| 1060 | HttpClientRequest.setHeader(request, "authorization", authorization), | 1080 | ...(Object.keys(headers).length > 0 |
| 1061 | ), | 1081 | ? { |
| 1062 | } | 1082 | transformClient: HttpClient.mapRequest((request) => |
| 1063 | : {}), | 1083 | HttpClientRequest.setHeaders(request, headers), |
| 1084 | ), | ||
| 1085 | } | ||
| 1086 | : {}), | ||
| 1087 | // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite | ||
| 1088 | // the transport-level error into the login hint. Without this the client | ||
| 1089 | // fails decoding the unexpected status and prints the opaque | ||
| 1090 | // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) | ||
| 1091 | // decode before this catch and pass through untouched. | ||
| 1092 | transformResponse: (effect) => | ||
| 1093 | Effect.catchIf( | ||
| 1094 | effect, | ||
| 1095 | (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, | ||
| 1096 | () => | ||
| 1097 | Effect.fail( | ||
| 1098 | new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })), | ||
| 1099 | ), | ||
| 1100 | ), | ||
| 1101 | }); | ||
| 1064 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 1102 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1065 | }; | ||
| 1066 | 1103 | ||
| 1067 | // --------------------------------------------------------------------------- | 1104 | // --------------------------------------------------------------------------- |
| 1068 | // Foreground session | 1105 | // Foreground session |
| @@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines | |||
| 1321 | // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. | 1358 | // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. |
| 1322 | // --------------------------------------------------------------------------- | 1359 | // --------------------------------------------------------------------------- |
| 1323 | 1360 | ||
| 1324 | const mcpUrlForActiveLocalServer = ( | 1361 | const mcpUrlForActiveLocalServer = (input: { |
| 1325 | connection: ExecutorServerConnection, | 1362 | readonly connection: ExecutorServerConnection; |
| 1326 | elicitationMode: "browser" | "model", | 1363 | readonly elicitationMode: "browser" | "model"; |
| 1327 | ): URL => { | 1364 | readonly artifacts: boolean; |
| 1328 | const url = new URL("/mcp", connection.origin); | 1365 | readonly searchTools: boolean; |
| 1329 | if (elicitationMode === "browser") { | 1366 | }): URL => { |
| 1367 | const url = new URL("/mcp", input.connection.origin); | ||
| 1368 | if (input.elicitationMode === "browser") { | ||
| 1330 | url.searchParams.set("elicitation_mode", "browser"); | 1369 | url.searchParams.set("elicitation_mode", "browser"); |
| 1331 | } | 1370 | } |
| 1371 | // Artifacts are on by default; only the opt-out is spelled out, so the | ||
| 1372 | // default endpoint stays clean. | ||
| 1373 | if (!input.artifacts) { | ||
| 1374 | url.searchParams.set("artifacts", "false"); | ||
| 1375 | } | ||
| 1376 | // Per-integration search tools are off by default; only the opt-in is | ||
| 1377 | // spelled out. | ||
| 1378 | if (input.searchTools) { | ||
| 1379 | url.searchParams.set("search_tools", "true"); | ||
| 1380 | } | ||
| 1332 | return url; | 1381 | return url; |
| 1333 | }; | 1382 | }; |
| 1334 | 1383 | ||
| @@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines | |||
| 1343 | const runMcpHttpBridge = async (input: { | 1392 | const runMcpHttpBridge = async (input: { |
| 1344 | readonly manifest: ExecutorLocalServerManifest; | 1393 | readonly manifest: ExecutorLocalServerManifest; |
| 1345 | readonly elicitationMode: "browser" | "model"; | 1394 | readonly elicitationMode: "browser" | "model"; |
| 1395 | readonly artifacts: boolean; | ||
| 1396 | readonly searchTools: boolean; | ||
| 1346 | }): Promise<void> => { | 1397 | }): Promise<void> => { |
| 1347 | const stdio = new StdioServerTransport(); | 1398 | const stdio = new StdioServerTransport(); |
| 1348 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); | 1399 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); |
| 1349 | const http = new StreamableHTTPClientTransport( | 1400 | const http = new StreamableHTTPClientTransport( |
| 1350 | mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), | 1401 | mcpUrlForActiveLocalServer({ |
| 1402 | connection: input.manifest.connection, | ||
| 1403 | elicitationMode: input.elicitationMode, | ||
| 1404 | artifacts: input.artifacts, | ||
| 1405 | searchTools: input.searchTools, | ||
| 1406 | }), | ||
| 1351 | authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, | 1407 | authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, |
| 1352 | ); | 1408 | ); |
| 1353 | 1409 | ||
| @@ -1422,7 +1478,11 @@ const runMcpHttpBridge = async (input: { · 68 unmodified lines | |||
| 1422 | } | 1478 | } |
| 1423 | }; | 1479 | }; |
| 1424 | 1480 | ||
| 1425 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => | 1481 | const runStdioMcpSession = (input: { |
| 1482 | readonly elicitationMode: "browser" | "model"; | ||
| 1483 | readonly artifacts: boolean; | ||
| 1484 | readonly searchTools: boolean; | ||
| 1485 | }) => | ||
| 1426 | Effect.gen(function* () { | 1486 | Effect.gen(function* () { |
| 1427 | // `executor mcp` never owns the local database. If a local server is already | 1487 | // `executor mcp` never owns the local database. If a local server is already |
| 1428 | // running, bridge this stdio client to it; otherwise ensure a durable | 1488 | // running, bridge this stdio client to it; otherwise ensure a durable |
| @@ -1434,7 +1494,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 5 unmodified lines | |||
| 1434 | const active = yield* readActiveLocalServerManifest(); | 1494 | const active = yield* readActiveLocalServerManifest(); |
| 1435 | if (active) { | 1495 | if (active) { |
| 1436 | yield* Effect.promise(() => | 1496 | yield* Effect.promise(() => |
| 1437 | runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), | 1497 | runMcpHttpBridge({ |
| 1498 | manifest: active, | ||
| 1499 | elicitationMode: input.elicitationMode, | ||
| 1500 | artifacts: input.artifacts, | ||
| 1501 | searchTools: input.searchTools, | ||
| 1502 | }), | ||
| 1438 | ); | 1503 | ); |
| 1439 | return; | 1504 | return; |
| 1440 | } | 1505 | } |
| @@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines | |||
| 1456 | ); | 1521 | ); |
| 1457 | } | 1522 | } |
| 1458 | yield* Effect.promise(() => | 1523 | yield* Effect.promise(() => |
| 1459 | runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }), | 1524 | runMcpHttpBridge({ |
| 1525 | manifest: elected, | ||
| 1526 | elicitationMode: input.elicitationMode, | ||
| 1527 | artifacts: input.artifacts, | ||
| 1528 | searchTools: input.searchTools, | ||
| 1529 | }), | ||
| 1460 | ); | 1530 | ); |
| 1461 | }); | 1531 | }); |
| 1462 | 1532 | ||
| @@ -1504,14 +1574,45 @@ const parseOptionalJsonObject = ( · 41 unmodified lines | |||
| 1504 | Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)), | 1574 | Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)), |
| 1505 | ); | 1575 | ); |
| 1506 | 1576 | ||
| 1577 | const ownMessage = (value: unknown): string => { | ||
| 1578 | if (typeof value === "string") return value.trim(); | ||
| 1579 | if (typeof value === "object" && value !== null && "message" in value) { | ||
| 1580 | const message = (value as { message: unknown }).message; | ||
| 1581 | if (typeof message === "string") return message.trim(); | ||
| 1582 | } | ||
| 1583 | return ""; | ||
| 1584 | }; | ||
| 1585 | |||
| 1586 | /** | ||
| 1587 | * Render an unknown failure by walking its `cause` chain. Wrapper errors | ||
| 1588 | * (tagged errors carrying only `{ cause }`) have an empty own message; without | ||
| 1589 | * descending, a boot failure like a data-migration error printed as literally | ||
| 1590 | * "Unknown error" (issue #1403). Nested messages that merely repeat their | ||
| 1591 | * parent are dropped. | ||
| 1592 | */ | ||
| 1507 | const formatUnknownMessage = (cause: unknown): string => { | 1593 | const formatUnknownMessage = (cause: unknown): string => { |
| 1508 | if (cause instanceof Error) return cause.message; | 1594 | const messages: string[] = []; |
| 1509 | if (typeof cause === "string") return cause; | 1595 | const seen = new Set<unknown>(); |
| 1510 | if (typeof cause === "object" && cause !== null && "message" in cause) { | 1596 | let current: unknown = cause; |
| 1511 | const message = cause.message; | 1597 | while (current !== null && current !== undefined && !seen.has(current)) { |
| 1512 | if (typeof message === "string") return message; | 1598 | seen.add(current); |
| 1599 | const message = ownMessage(current); | ||
| 1600 | if (message.length > 0 && !messages.some((existing) => existing.includes(message))) { | ||
| 1601 | messages.push(message); | ||
| 1602 | } | ||
| 1603 | current = | ||
| 1604 | typeof current === "object" && "cause" in current | ||
| 1605 | ? (current as { cause: unknown }).cause | ||
| 1606 | : undefined; | ||
| 1513 | } | 1607 | } |
| 1514 | return String(cause); | 1608 | if (messages.length === 0) { |
| 1609 | return typeof cause === "string" | ||
| 1610 | ? cause | ||
| 1611 | : cause instanceof Error | ||
| 1612 | ? cause.message | ||
| 1613 | : String(cause); | ||
| 1614 | } | ||
| 1615 | return messages.join("\ncaused by: "); | ||
| 1515 | }; | 1616 | }; |
| 1516 | 1617 | ||
| 1517 | const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => { | 1618 | const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => { |
| @@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines | |||
| 1802 | Effect.gen(function* () { | 1903 | Effect.gen(function* () { |
| 1803 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); | 1904 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); |
| 1804 | 1905 | ||
| 1805 | const connection = yield* resolveExecutorServerConnection({ | 1906 | const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName }; |
| 1806 | baseUrl: args.baseUrl, | 1907 | const connection = yield* resolveExecutorServerConnection(target); |
| 1807 | serverName: args.serverName, | 1908 | const client = yield* makeApiClient(connection, target); |
| 1808 | }); | ||
| 1809 | const client = yield* makeApiClient(connection); | ||
| 1810 | const tools = yield* client.tools.list({ query: {} }); | 1909 | const tools = yield* client.tools.list({ query: {} }); |
| 1811 | const toolPaths = tools.map((tool) => tool.address); | 1910 | const toolPaths = tools.map((tool) => tool.address); |
| 1812 | 1911 | ||
| @@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines | |||
| 1987 | 2086 | ||
| 1988 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); | 2087 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); |
| 1989 | 2088 | ||
| 1990 | const client = yield* makeApiClient(connection); | 2089 | const client = yield* makeApiClient(connection, target); |
| 1991 | const result = yield* client.executions.resume({ | 2090 | const result = yield* client.executions.resume({ |
| 1992 | params: { executionId }, | 2091 | params: { executionId }, |
| 1993 | payload: { action, content: contentObj }, | 2092 | payload: { action, content: contentObj }, |
| @@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines | |||
| 2052 | }), | 2151 | }), |
| 2053 | ).pipe(Command.withDescription("Search tools by natural-language query")); | 2152 | ).pipe(Command.withDescription("Search tools by natural-language query")); |
| 2054 | 2153 | ||
| 2055 | const toolsSourcesCommand = Command.make( | 2154 | const toolsIntegrationsCommand = Command.make( |
| 2056 | "sources", | 2155 | "integrations", |
| 2057 | { | 2156 | { |
| 2058 | query: Options.string("query").pipe(Options.optional), | 2157 | query: Options.string("query").pipe(Options.optional), |
| 2059 | limit: Options.integer("limit").pipe(Options.withDefault(50)), | 2158 | limit: Options.integer("limit").pipe(Options.withDefault(50)), |
| @@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | |||
| 2065 | Effect.gen(function* () { | 2164 | Effect.gen(function* () { |
| 2066 | applyScope(scope); | 2165 | applyScope(scope); |
| 2067 | const target = serverTargetFromOptions({ baseUrl, server }); | 2166 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 2068 | const code = buildListSourcesCode({ | 2167 | const code = buildListIntegrationsCode({ |
| 2069 | query: Option.getOrUndefined(query), | 2168 | query: Option.getOrUndefined(query), |
| 2070 | limit, | 2169 | limit, |
| 2071 | }); | 2170 | }); |
| @@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | |||
| 2077 | outcome: result.outcome, | 2176 | outcome: result.outcome, |
| 2078 | }); | 2177 | }); |
| 2079 | }), | 2178 | }), |
| 2080 | ).pipe(Command.withDescription("List configured sources and tool counts")); | 2179 | ).pipe(Command.withDescription("List configured integrations and tool counts")); |
| 2081 | 2180 | ||
| 2082 | const toolsDescribeCommand = Command.make( | 2181 | const toolsDescribeCommand = Command.make( |
| 2083 | "describe", | 2182 | "describe", |
| @@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines | |||
| 2102 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); | 2201 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); |
| 2103 | 2202 | ||
| 2104 | const toolsCommand = Command.make("tools").pipe( | 2203 | const toolsCommand = Command.make("tools").pipe( |
| 2105 | Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const), | 2204 | Command.withSubcommands([ |
| 2106 | Command.withDescription("Discover available tools and sources"), | 2205 | toolsSearchCommand, |
| 2206 | toolsIntegrationsCommand, | ||
| 2207 | toolsDescribeCommand, | ||
| 2208 | ] as const), | ||
| 2209 | Command.withDescription("Discover available tools and integrations"), | ||
| 2107 | ); | 2210 | ); |
| 2108 | 2211 | ||
| 2212 | const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; | ||
| 2213 | const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; | ||
| 2214 | |||
| 2215 | const parseHeaderEnvOption = ( | ||
| 2216 | headerEnv: Option.Option<Record<string, string>>, | ||
| 2217 | ): ExecutorServerHeaders | undefined => { | ||
| 2218 | const values = Option.getOrUndefined(headerEnv); | ||
| 2219 | if (!values) return undefined; | ||
| 2220 | const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {}; | ||
| 2221 | for (const [rawHeaderName, rawEnvName] of Object.entries(values)) { | ||
| 2222 | const headerName = rawHeaderName.trim(); | ||
| 2223 | const envName = rawEnvName.trim(); | ||
| 2224 | if (!HEADER_NAME_PATTERN.test(headerName)) { | ||
| 2225 | throw new Error( | ||
| 2226 | `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`, | ||
| 2227 | ); | ||
| 2228 | } | ||
| 2229 | if (!ENV_NAME_PATTERN.test(envName)) { | ||
| 2230 | throw new Error( | ||
| 2231 | `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`, | ||
| 2232 | ); | ||
| 2233 | } | ||
| 2234 | headers[headerName] = { kind: "env", name: envName }; | ||
| 2235 | } | ||
| 2236 | return Object.keys(headers).length > 0 ? headers : undefined; | ||
| 2237 | }; | ||
| 2238 | |||
| 2109 | const profileConnectionInput = (input: { | 2239 | const profileConnectionInput = (input: { |
| 2110 | readonly origin: string; | 2240 | readonly origin: string; |
| 2111 | readonly displayName: Option.Option<string>; | 2241 | readonly displayName: Option.Option<string>; |
| 2112 | readonly kind: Option.Option<"http" | "desktop-sidecar">; | 2242 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2243 | readonly headerEnv: Option.Option<Record<string, string>>; | ||
| 2113 | }): ExecutorServerConnectionInput => { | 2244 | }): ExecutorServerConnectionInput => { |
| 2114 | const selectedKind = Option.getOrUndefined(input.kind); | 2245 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2115 | const displayName = Option.getOrUndefined(input.displayName); | 2246 | const displayName = Option.getOrUndefined(input.displayName); |
| 2247 | const headers = parseHeaderEnvOption(input.headerEnv); | ||
| 2116 | return { | 2248 | return { |
| 2117 | kind: selectedKind ?? "http", | 2249 | kind: selectedKind ?? "http", |
| 2118 | origin: input.origin, | 2250 | origin: input.origin, |
| 2119 | ...(displayName ? { displayName } : {}), | 2251 | ...(displayName ? { displayName } : {}), |
| 2252 | ...(headers ? { headers } : {}), | ||
| 2120 | }; | 2253 | }; |
| 2121 | }; | 2254 | }; |
| 2122 | 2255 | ||
| @@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines | |||
| 2136 | origin: profile.connection.origin, | 2269 | origin: profile.connection.origin, |
| 2137 | displayName: profile.connection.displayName, | 2270 | displayName: profile.connection.displayName, |
| 2138 | auth: profile.connection.auth ? "stored-auth" : "env-auth", | 2271 | auth: profile.connection.auth ? "stored-auth" : "env-auth", |
| 2272 | headers: profile.connection.headers | ||
| 2273 | ? `${Object.keys(profile.connection.headers).length} header-env` | ||
| 2274 | : "no-headers", | ||
| 2139 | })); | 2275 | })); |
| 2140 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); | 2276 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2141 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); | 2277 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2142 | 2278 | ||
| 2143 | for (const row of rows) { | 2279 | for (const row of rows) { |
| 2144 | console.log( | 2280 | console.log( |
| 2145 | `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, | 2281 | `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`, |
| 2146 | ); | 2282 | ); |
| 2147 | } | 2283 | } |
| 2148 | }); | 2284 | }); |
| @@ -2160,17 +2296,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines | |||
| 2160 | Options.optional, | 2296 | Options.optional, |
| 2161 | Options.withDescription("Server kind. Defaults to http."), | 2297 | Options.withDescription("Server kind. Defaults to http."), |
| 2162 | ), | 2298 | ), |
| 2299 | headerEnv: Options.keyValuePair("header-env").pipe( | ||
| 2300 | Options.optional, | ||
| 2301 | Options.withDescription( | ||
| 2302 | "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.", | ||
| 2303 | ), | ||
| 2304 | ), | ||
| 2163 | makeDefault: Options.boolean("default").pipe( | 2305 | makeDefault: Options.boolean("default").pipe( |
| 2164 | Options.withDefault(false), | 2306 | Options.withDefault(false), |
| 2165 | Options.withDescription("Make this profile the default server."), | 2307 | Options.withDescription("Make this profile the default server."), |
| 2166 | ), | 2308 | ), |
| 2167 | }, | 2309 | }, |
| 2168 | ({ name, origin, displayName, kind, makeDefault }) => | 2310 | ({ name, origin, displayName, kind, headerEnv, makeDefault }) => |
| 2169 | Effect.gen(function* () { | 2311 | Effect.gen(function* () { |
| 2170 | const profileName = validateCliServerConnectionProfileName(name); | 2312 | const profileName = validateCliServerConnectionProfileName(name); |
| 2171 | const store = yield* upsertCliServerConnectionProfile({ | 2313 | const store = yield* upsertCliServerConnectionProfile({ |
| 2172 | name: profileName, | 2314 | name: profileName, |
| 2173 | connection: profileConnectionInput({ origin, displayName, kind }), | 2315 | connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), |
| 2174 | makeDefault, | 2316 | makeDefault, |
| 2175 | }); | 2317 | }); |
| 2176 | const profile = findCliServerConnectionProfile(store, profileName); | 2318 | const profile = findCliServerConnectionProfile(store, profileName); |
| @@ -2307,9 +2449,13 @@ const chooseLoginProfileName = ( · 130 unmodified lines | |||
| 2307 | } | 2449 | } |
| 2308 | }; | 2450 | }; |
| 2309 | 2451 | ||
| 2452 | /** Where `executor login` lands with no flags and no stored profiles. */ | ||
| 2453 | const DEFAULT_LOGIN_ORIGIN = "https://executor.sh"; | ||
| 2454 | |||
| 2310 | // Resolve which server a login/logout targets: an existing profile (--server | 2455 | // Resolve which server a login/logout targets: an existing profile (--server |
| 2311 | // or the default) or a bare origin (--base-url). The profile name is decided | 2456 | // or the default), a bare origin (--base-url), or hosted Executor when |
| 2312 | // later, from the authenticated account. | 2457 | // nothing is configured yet. The profile name is decided later, from the |
| 2458 | // authenticated account. | ||
| 2313 | const resolveLoginOrigin = (input: { | 2459 | const resolveLoginOrigin = (input: { |
| 2314 | readonly baseUrl: Option.Option<string>; | 2460 | readonly baseUrl: Option.Option<string>; |
| 2315 | readonly server: Option.Option<string>; | 2461 | readonly server: Option.Option<string>; |
| @@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines | |||
| 2337 | const store = yield* readCliServerConnectionStore(); | 2483 | const store = yield* readCliServerConnectionStore(); |
| 2338 | const profile = defaultCliServerConnectionProfile(store); | 2484 | const profile = defaultCliServerConnectionProfile(store); |
| 2339 | if (profile) return { origin: profile.connection.origin, profile }; | 2485 | if (profile) return { origin: profile.connection.origin, profile }; |
| 2340 | return yield* Effect.fail( | 2486 | return { origin: DEFAULT_LOGIN_ORIGIN, profile: null }; |
| 2341 | new Error( | ||
| 2342 | "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.", | ||
| 2343 | ), | ||
| 2344 | ); | ||
| 2345 | }); | 2487 | }); |
| 2346 | 2488 | ||
| 2347 | const loginNameOption = Options.string("name").pipe( | 2489 | const loginNameOption = Options.string("name").pipe( |
| @@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines | |||
| 2366 | Effect.gen(function* () { | 2508 | Effect.gen(function* () { |
| 2367 | const target = yield* resolveLoginOrigin({ baseUrl, server }); | 2509 | const target = yield* resolveLoginOrigin({ baseUrl, server }); |
| 2368 | const explicitName = Option.getOrUndefined(name); | 2510 | const explicitName = Option.getOrUndefined(name); |
| 2511 | // The target may have been picked implicitly (default profile, or the | ||
| 2512 | // hosted fallback): say where the login is going before the device flow. | ||
| 2513 | console.log(`Signing in to ${target.origin}`); | ||
| 2514 | const targetProfile = target.profile; | ||
| 2515 | const headers = targetProfile | ||
| 2516 | ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe( | ||
| 2517 | Effect.mapError(toError), | ||
| 2518 | ) | ||
| 2519 | : {}; | ||
| 2369 | const discovery = yield* Effect.tryPromise({ | 2520 | const discovery = yield* Effect.tryPromise({ |
| 2370 | try: () => discoverCliLogin(target.origin), | 2521 | try: () => discoverCliLogin(target.origin, { headers }), |
| 2371 | catch: toError, | 2522 | catch: toError, |
| 2372 | }); | 2523 | }); |
| 2373 | const grant = yield* Effect.tryPromise({ | 2524 | const grant = yield* Effect.tryPromise({ |
| 2374 | try: () => requestDeviceCode(discovery), | 2525 | try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), |
| 2375 | catch: toError, | 2526 | catch: toError, |
| 2376 | }); | 2527 | }); |
| 2377 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; | 2528 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines | |||
| 2382 | if (!noBrowser) openBrowser(verifyUrl); | 2533 | if (!noBrowser) openBrowser(verifyUrl); |
| 2383 | console.log("Waiting for you to approve in the browser..."); | 2534 | console.log("Waiting for you to approve in the browser..."); |
| 2384 | const tokens = yield* Effect.tryPromise({ | 2535 | const tokens = yield* Effect.tryPromise({ |
| 2385 | try: () => pollForDeviceTokens(discovery, grant), | 2536 | try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), |
| 2386 | catch: toError, | 2537 | catch: toError, |
| 2387 | }); | 2538 | }); |
| 2388 | 2539 | ||
| @@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines | |||
| 2407 | kind: "http", | 2558 | kind: "http", |
| 2408 | origin: target.origin, | 2559 | origin: target.origin, |
| 2409 | ...(email ? { displayName: email } : {}), | 2560 | ...(email ? { displayName: email } : {}), |
| 2561 | ...(target.profile?.connection.headers | ||
| 2562 | ? { headers: target.profile.connection.headers } | ||
| 2563 | : {}), | ||
| 2410 | auth: { | 2564 | auth: { |
| 2411 | kind: "oauth", | 2565 | kind: "oauth", |
| 2412 | accessToken: tokens.accessToken, | 2566 | accessToken: tokens.accessToken, |
| @@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines | |||
| 2420 | }); | 2574 | }); |
| 2421 | 2575 | ||
| 2422 | console.log(""); | 2576 | console.log(""); |
| 2423 | console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`); | 2577 | // Profile bookkeeping stays backstage unless the user works with named |
| 2578 | // profiles (--name, or a store that already has several). | ||
| 2579 | const mentionProfile = | ||
| 2580 | explicitName !== undefined || (yield* readCliServerConnectionStore()).profiles.length > 1; | ||
| 2581 | console.log( | ||
| 2582 | mentionProfile | ||
| 2583 | ? `Logged in to ${target.origin} (profile "${profileName}", now the default).` | ||
| 2584 | : `Logged in to ${target.origin}.`, | ||
| 2585 | ); | ||
| 2424 | if (email) console.log(`Account: ${email}`); | 2586 | if (email) console.log(`Account: ${email}`); |
| 2425 | if (org) console.log(`Organization: ${org}`); | 2587 | if (org) console.log(`Organization: ${org}`); |
| 2426 | else if (sub) console.log(`User: ${sub}`); | 2588 | else if (sub) console.log(`User: ${sub}`); |
| 2427 | }), | 2589 | }), |
| 2428 | ).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)")); | 2590 | ).pipe( |
| 2591 | Command.withDescription( | ||
| 2592 | "Sign in to a hosted Executor server in the browser (device flow). Defaults to https://executor.sh.", | ||
| 2593 | ), | ||
| 2594 | ); | ||
| 2429 | 2595 | ||
| 2430 | const logoutCommand = Command.make( | 2596 | const logoutCommand = Command.make( |
| 2431 | "logout", | 2597 | "logout", |
| @@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines | |||
| 2441 | console.log(`No stored login for ${target.origin}.`); | 2607 | console.log(`No stored login for ${target.origin}.`); |
| 2442 | return; | 2608 | return; |
| 2443 | } | 2609 | } |
| 2610 | // Profile bookkeeping stays backstage unless the user addressed one. | ||
| 2611 | const mentionProfile = Option.isSome(server) || store.profiles.length > 1; | ||
| 2444 | if (!profile.connection.auth) { | 2612 | if (!profile.connection.auth) { |
| 2445 | console.log(`Profile "${profile.name}" has no stored credentials.`); | 2613 | console.log( |
| 2614 | mentionProfile | ||
| 2615 | ? `Profile "${profile.name}" has no stored credentials.` | ||
| 2616 | : `Not signed in to ${target.origin}.`, | ||
| 2617 | ); | ||
| 2446 | return; | 2618 | return; |
| 2447 | } | 2619 | } |
| 2448 | yield* upsertCliServerConnectionProfile({ | 2620 | yield* upsertCliServerConnectionProfile({ |
| @@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines | |||
| 2451 | kind: profile.connection.kind, | 2623 | kind: profile.connection.kind, |
| 2452 | origin: profile.connection.origin, | 2624 | origin: profile.connection.origin, |
| 2453 | displayName: profile.connection.displayName, | 2625 | displayName: profile.connection.displayName, |
| 2626 | ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | ||
| 2454 | }, | 2627 | }, |
| 2455 | makeDefault: store.defaultProfile === profile.name, | 2628 | makeDefault: store.defaultProfile === profile.name, |
| 2456 | }); | 2629 | }); |
| 2457 | console.log( | 2630 | console.log( |
| 2458 | `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`, | 2631 | mentionProfile |
| 2632 | ? `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").` | ||
| 2633 | : `Logged out of ${profile.connection.origin}.`, | ||
| 2459 | ); | 2634 | ); |
| 2460 | }), | 2635 | }), |
| 2461 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); | 2636 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); |
| @@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines | |||
| 2709 | "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", | 2884 | "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", |
| 2710 | ), | 2885 | ), |
| 2711 | ), | 2886 | ), |
| 2887 | noArtifacts: Options.boolean("no-artifacts") | ||
| 2888 | .pipe(Options.withDefault(false)) | ||
| 2889 | .pipe( | ||
| 2890 | Options.withDescription( | ||
| 2891 | "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.", | ||
| 2892 | ), | ||
| 2893 | ), | ||
| 2894 | searchTools: Options.boolean("search-tools") | ||
| 2895 | .pipe(Options.withDefault(false)) | ||
| 2896 | .pipe( | ||
| 2897 | Options.withDescription( | ||
| 2898 | "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.", | ||
| 2899 | ), | ||
| 2900 | ), | ||
| 2712 | }, | 2901 | }, |
| 2713 | ({ scope, elicitationMode }) => | 2902 | ({ scope, elicitationMode, noArtifacts, searchTools }) => |
| 2714 | Effect.gen(function* () { | 2903 | Effect.gen(function* () { |
| 2715 | applyScope(scope); | 2904 | applyScope(scope); |
| 2716 | yield* runStdioMcpSession({ elicitationMode }); | 2905 | yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); |
| 2717 | }), | 2906 | }), |
| 2718 | ).pipe(Command.withDescription("Start an MCP server over stdio")); | 2907 | ).pipe(Command.withDescription("Start an MCP server over stdio")); |
| 2719 | 2908 | ||
| @@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines | |||
| 3164 | } else { | 3353 | } else { |
| 3165 | console.error(renderCliError(cause)); | 3354 | console.error(renderCliError(cause)); |
| 3166 | } | 3355 | } |
| 3167 | process.exitCode = 1; | 3356 | // Exit hard, not via exitCode: this handler converts the failure into a |
| 3357 | // success, so runMain's teardown never force-exits, and background fibers | ||
| 3358 | // (the integrations-refresh fork) keep the event loop alive. A daemon | ||
| 3359 | // that failed boot then lingers with its port unbound, and the desktop | ||
| 3360 | // waits on the ready sentinel forever (issue #1403). | ||
| 3361 | process.exit(1); | ||
| 3168 | }), | 3362 | }), |
| 3169 | ), | 3363 | ), |
| 3170 | ); | 3364 | ); |
| @@ -1,11 +1,10 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Native-binding bootstrap for the `bun build --compile` binary. | 2 | // Native-binding bootstrap for the `bun build --compile` binary. |
| 3 | // | 3 | // |
| 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native | 4 | // `bun --compile` bundles JS into bunfs but does NOT include native binaries, |
| 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | 5 | // wasm sidecars, or runtime-read package files. build.ts copies those artifacts |
| 6 | // the binary fails. build.ts copies each platform's `.node` next to the | 6 | // next to the executable; here we publish their on-disk paths via env vars the |
| 7 | // executable (`libsql.node`, `keyring.node`); here we publish their on-disk | 7 | // loaders read. |
| 8 | // paths via env vars the loaders read. | ||
| 9 | // | 8 | // |
| 10 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import | 9 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import |
| 11 | // before the importer's own body, and libSQL resolves its native addon EAGERLY | 10 | // before the importer's own body, and libSQL resolves its native addon EAGERLY |
| @@ -18,6 +17,8 @@ · 6 unmodified lines | |||
| 18 | import { existsSync } from "node:fs"; | 17 | import { existsSync } from "node:fs"; |
| 19 | import { dirname, join } from "node:path"; | 18 | import { dirname, join } from "node:path"; |
| 20 | 19 | ||
| 20 | import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | ||
| 21 | |||
| 21 | const execDir = dirname(process.execPath); | 22 | const execDir = dirname(process.execPath); |
| 22 | 23 | ||
| 23 | // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the | 24 | // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the |
| @@ -43,3 +44,33 @@ if ( · 19 unmodified lines | |||
| 43 | ) { | 44 | ) { |
| 44 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; | 45 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; |
| 45 | } | 46 | } |
| 47 | |||
| 48 | const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd"); | ||
| 49 | if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) { | ||
| 50 | process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk; | ||
| 51 | } | ||
| 52 | |||
| 53 | // worker-bundler: the compiled binary cannot resolve `@cloudflare/worker-bundler` | ||
| 54 | // by name (bunfs has no node_modules), so build.ts stages the package's dist | ||
| 55 | // beside the executable and we publish its path here. An absent directory is | ||
| 56 | // normal off the packaged path (dev, `bun run`), so that stays quiet — but a | ||
| 57 | // directory that is PRESENT AND INCOMPLETE is a broken install, and swallowing | ||
| 58 | // it is what turns a packaging slip into an unresolvable bare import at | ||
| 59 | // startup. Report it on stderr instead of failing open. | ||
| 60 | const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME); | ||
| 61 | if ( | ||
| 62 | typeof Bun !== "undefined" && | ||
| 63 | !process.env.EXECUTOR_WORKER_BUNDLER_DIR && | ||
| 64 | existsSync(workerBundlerOnDisk) | ||
| 65 | ) { | ||
| 66 | const missing = missingWorkerBundlerFiles(workerBundlerOnDisk, existsSync); | ||
| 67 | if (missing.length === 0) { | ||
| 68 | process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; | ||
| 69 | } else { | ||
| 70 | process.stderr.write( | ||
| 71 | `executor: the bundled Worker toolchain at ${workerBundlerOnDisk} is incomplete ` + | ||
| 72 | `(missing ${missing.join(", ")}). Features that build Workers will be unavailable; ` + | ||
| 73 | `reinstall or update executor to repair it.\n`, | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | } | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 3 | import { | 5 | import { |
| 4 | canAutoStartCliServerConnection, | 6 | canAutoStartCliServerConnection, |
| 5 | chooseCliServerConnectionWithActiveLocal, | 7 | chooseCliServerConnectionWithActiveLocal, |
| 8 | describeUnauthorizedCliServer, | ||
| 6 | parseCliExecutorServerConnection, | 9 | parseCliExecutorServerConnection, |
| 7 | withCliServerAuthFallback, | 10 | withCliServerAuthFallback, |
| 8 | } from "./server-connection"; | 11 | } from "./server-connection"; |
| @@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines | |||
| 135 | ).toBe("conflict"); | 138 | ).toBe("conflict"); |
| 136 | }); | 139 | }); |
| 137 | }); | 140 | }); |
| 141 | |||
| 142 | describe("describeUnauthorizedCliServer", () => { | ||
| 143 | it("hints plain `executor login` when the server was picked implicitly", () => { | ||
| 144 | const connection = normalizeExecutorServerConnection({ | ||
| 145 | key: "profile:rhys-executor.sh", | ||
| 146 | origin: "https://executor.sh", | ||
| 147 | auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 }, | ||
| 148 | }); | ||
| 149 | |||
| 150 | const message = describeUnauthorizedCliServer({ | ||
| 151 | connection, | ||
| 152 | cliPrefix: "executor", | ||
| 153 | target: {}, | ||
| 154 | }); | ||
| 155 | expect(message).toContain("You're signed out of https://executor.sh"); | ||
| 156 | expect(message).toContain("Run `executor login` to sign in again."); | ||
| 157 | // Profile names are plumbing: never surfaced when the user didn't type one. | ||
| 158 | expect(message).not.toContain("--server"); | ||
| 159 | expect(message).not.toContain("profile"); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("echoes --server back when the user targeted a named profile", () => { | ||
| 163 | const connection = normalizeExecutorServerConnection({ | ||
| 164 | key: "profile:work", | ||
| 165 | origin: "https://executor.example", | ||
| 166 | auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 }, | ||
| 167 | }); | ||
| 168 | |||
| 169 | const message = describeUnauthorizedCliServer({ | ||
| 170 | connection, | ||
| 171 | cliPrefix: "executor", | ||
| 172 | target: { serverName: "work" }, | ||
| 173 | }); | ||
| 174 | expect(message).toContain("You're signed out of https://executor.example"); | ||
| 175 | expect(message).toContain("executor login --server work"); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("echoes --base-url back when the user targeted an origin", () => { | ||
| 179 | const connection = normalizeExecutorServerConnection({ | ||
| 180 | origin: "https://executor.example", | ||
| 181 | }); | ||
| 182 | |||
| 183 | const message = describeUnauthorizedCliServer({ | ||
| 184 | connection, | ||
| 185 | cliPrefix: "executor", | ||
| 186 | target: { baseUrl: "https://executor.example" }, | ||
| 187 | }); | ||
| 188 | expect(message).toContain("no credentials are stored"); | ||
| 189 | expect(message).toContain("executor login --base-url https://executor.example"); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("mentions the env vars when a profile-less bearer key is rejected", () => { | ||
| 193 | const connection = normalizeExecutorServerConnection({ | ||
| 194 | origin: "https://executor.example", | ||
| 195 | auth: { kind: "bearer", token: "key_bad" }, | ||
| 196 | }); | ||
| 197 | |||
| 198 | const message = describeUnauthorizedCliServer({ | ||
| 199 | connection, | ||
| 200 | cliPrefix: "executor", | ||
| 201 | target: {}, | ||
| 202 | }); | ||
| 203 | expect(message).toContain("rejected the stored bearer credentials"); | ||
| 204 | expect(message).toContain("EXECUTOR_API_KEY"); | ||
| 205 | }); | ||
| 206 | |||
| 207 | it("uses the dev entrypoint prefix verbatim", () => { | ||
| 208 | const connection = normalizeExecutorServerConnection({ | ||
| 209 | key: "profile:hosted", | ||
| 210 | origin: "https://executor.example", | ||
| 211 | auth: { kind: "oauth", accessToken: "stale" }, | ||
| 212 | }); | ||
| 213 | |||
| 214 | const message = describeUnauthorizedCliServer({ | ||
| 215 | connection, | ||
| 216 | cliPrefix: "bun run apps/cli/src/main.ts", | ||
| 217 | target: {}, | ||
| 218 | }); | ||
| 219 | expect(message).toContain("bun run apps/cli/src/main.ts login"); | ||
| 220 | }); | ||
| 221 | }); | ||
| @@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines | |||
| 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| 53 | }; | 53 | }; |
| 54 | 54 | ||
| 55 | export const profileNameFromConnectionKey = (key: string): string | null => | ||
| 56 | key.startsWith("profile:") ? key.slice("profile:".length) : null; | ||
| 57 | |||
| 58 | // A 401 from the server means the stored credential is stale, revoked, or | ||
| 59 | // missing: a sign-in problem, not a transport problem. Rendered instead of | ||
| 60 | // the raw HTTP client error so the user learns the recovery command, not the | ||
| 61 | // failing endpoint. | ||
| 62 | // | ||
| 63 | // The hint mirrors how the user addressed the server. The common case types | ||
| 64 | // no flags (the default profile, normally executor.sh), and login re-targets | ||
| 65 | // that same default, so the fix is plain `executor login`: profile names are | ||
| 66 | // plumbing and stay invisible. Only an explicit --server / --base-url comes | ||
| 67 | // back in the hint. | ||
| 68 | export const describeUnauthorizedCliServer = (input: { | ||
| 69 | readonly connection: ExecutorServerConnection; | ||
| 70 | readonly cliPrefix: string; | ||
| 71 | readonly target: { readonly baseUrl?: string; readonly serverName?: string }; | ||
| 72 | }): string => { | ||
| 73 | const { connection, cliPrefix, target } = input; | ||
| 74 | const loginCommand = target.serverName | ||
| 75 | ? `${cliPrefix} login --server ${target.serverName}` | ||
| 76 | : target.baseUrl | ||
| 77 | ? `${cliPrefix} login --base-url ${connection.origin}` | ||
| 78 | : `${cliPrefix} login`; | ||
| 79 | |||
| 80 | if (!connection.auth) { | ||
| 81 | return [ | ||
| 82 | `${connection.origin} requires authentication, and no credentials are stored for it.`, | ||
| 83 | `Run \`${loginCommand}\` to sign in.`, | ||
| 84 | ].join("\n"); | ||
| 85 | } | ||
| 86 | |||
| 87 | if (connection.auth.kind === "oauth") { | ||
| 88 | return [ | ||
| 89 | `You're signed out of ${connection.origin}.`, | ||
| 90 | `Run \`${loginCommand}\` to sign in again.`, | ||
| 91 | ].join("\n"); | ||
| 92 | } | ||
| 93 | |||
| 94 | const envHint = | ||
| 95 | connection.auth.kind === "bearer" && !profileNameFromConnectionKey(connection.key) | ||
| 96 | ? " If the key came from EXECUTOR_API_KEY or EXECUTOR_AUTH_TOKEN, check that value." | ||
| 97 | : ""; | ||
| 98 | return [ | ||
| 99 | `${connection.origin} rejected the stored ${connection.auth.kind} credentials (401).${envHint}`, | ||
| 100 | `Run \`${loginCommand}\` to sign in again.`, | ||
| 101 | ].join("\n"); | ||
| 102 | }; | ||
| 103 | |||
| 55 | export type CliServerConnectionSource = | 104 | export type CliServerConnectionSource = |
| 56 | | "explicit" | 105 | | "explicit" |
| 57 | | "default-profile" | 106 | | "default-profile" |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { BunServices } from "@effect/platform-bun"; | 2 | import { BunServices } from "@effect/platform-bun"; |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | 3 | import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines | |||
| 25 | }); | 25 | }); |
| 26 | 26 | ||
| 27 | describe("CLI server connection profiles", () => { | 27 | describe("CLI server connection profiles", () => { |
| 28 | it("round-trips named server connections and default selection", () => | 28 | it.effect("round-trips named server connections and default selection", () => |
| 29 | Effect.gen(function* () { | 29 | Effect.gen(function* () { |
| 30 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); | 30 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 31 | process.env.EXECUTOR_DATA_DIR = dataDir; | 31 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines | |||
| 36 | connection: { | 36 | connection: { |
| 37 | origin: "https://executor.example/api", | 37 | origin: "https://executor.example/api", |
| 38 | auth: { kind: "bearer", token: "key_123" }, | 38 | auth: { kind: "bearer", token: "key_123" }, |
| 39 | headers: { | ||
| 40 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 41 | }, | ||
| 39 | }, | 42 | }, |
| 40 | makeDefault: true, | 43 | makeDefault: true, |
| 41 | }); | 44 | }); |
| @@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | |||
| 50 | kind: "bearer", | 53 | kind: "bearer", |
| 51 | token: "key_123", | 54 | token: "key_123", |
| 52 | }); | 55 | }); |
| 56 | expect(store.profiles[0]?.connection.headers).toEqual({ | ||
| 57 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 58 | }); | ||
| 53 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | } finally { | 60 | } finally { |
| 55 | rmSync(dataDir, { recursive: true, force: true }); | 61 | rmSync(dataDir, { recursive: true, force: true }); |
| 56 | } | 62 | } |
| 57 | }).pipe(Effect.provide(BunServices.layer))); | 63 | }).pipe(Effect.provide(BunServices.layer)), |
| 64 | ); | ||
| 58 | 65 | ||
| 59 | it("switches and removes the default profile", () => | 66 | it.effect("switches and removes the default profile", () => |
| 60 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 61 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); | 68 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 62 | process.env.EXECUTOR_DATA_DIR = dataDir; | 69 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines | |||
| 82 | } finally { | 89 | } finally { |
| 83 | rmSync(dataDir, { recursive: true, force: true }); | 90 | rmSync(dataDir, { recursive: true, force: true }); |
| 84 | } | 91 | } |
| 85 | }).pipe(Effect.provide(BunServices.layer))); | 92 | }).pipe(Effect.provide(BunServices.layer)), |
| 93 | ); | ||
| 86 | 94 | ||
| 87 | it("drops malformed profiles when parsing", () => { | 95 | it("drops malformed profiles when parsing", () => { |
| 88 | const store = parseCliServerConnectionStore( | 96 | const store = parseCliServerConnectionStore( |
| @@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines | |||
| 114 | key: "desktop-sidecar", | 122 | key: "desktop-sidecar", |
| 115 | origin: "http://127.0.0.1:4789", | 123 | origin: "http://127.0.0.1:4789", |
| 116 | auth: { kind: "basic", username: "executor", password: "secret" }, | 124 | auth: { kind: "basic", username: "executor", password: "secret" }, |
| 125 | headers: { | ||
| 126 | "CF-Access-Client-Id": { | ||
| 127 | kind: "env", | ||
| 128 | name: "EXECUTOR_CF_ACCESS_CLIENT_ID", | ||
| 129 | }, | ||
| 130 | }, | ||
| 117 | }, | 131 | }, |
| 118 | }, | 132 | }, |
| 119 | ], | 133 | ], |
| @@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines | |||
| 127 | username: "executor", | 141 | username: "executor", |
| 128 | password: "secret", | 142 | password: "secret", |
| 129 | }); | 143 | }); |
| 144 | expect(store.profiles[0]?.connection.headers).toEqual({ | ||
| 145 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 146 | }); | ||
| 130 | }); | 147 | }); |
| 148 | |||
| 149 | // ------------------------------------------------------------------------- | ||
| 150 | // File permissions. | ||
| 151 | // | ||
| 152 | // This store holds live credentials for a hosted server — a bearer token, or | ||
| 153 | // an OAuth access token and its long-lived refresh token. Nothing pinned its | ||
| 154 | // mode before, so it was created with the process umask (0644 by default) and | ||
| 155 | // readable by every other account on the machine. | ||
| 156 | // | ||
| 157 | // Both cases are covered because they need different mechanisms: `mode` on | ||
| 158 | // create, and a `chmod` for the overwrite. The overwrite is the common path | ||
| 159 | // here, since the store is rewritten on every silent token refresh. | ||
| 160 | // ------------------------------------------------------------------------- | ||
| 161 | |||
| 162 | it.effect("creates the credential store owner-only", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-mode-")); | ||
| 165 | process.env.EXECUTOR_DATA_DIR = dataDir; | ||
| 166 | |||
| 167 | try { | ||
| 168 | yield* upsertCliServerConnectionProfile({ | ||
| 169 | name: "remote", | ||
| 170 | connection: { | ||
| 171 | origin: "https://executor.example", | ||
| 172 | auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, | ||
| 173 | }, | ||
| 174 | makeDefault: true, | ||
| 175 | }); | ||
| 176 | |||
| 177 | const mode = statSync(join(dataDir, "server-connections.json")).mode & 0o777; | ||
| 178 | expect(mode).toBe(0o600); | ||
| 179 | } finally { | ||
| 180 | rmSync(dataDir, { recursive: true, force: true }); | ||
| 181 | } | ||
| 182 | }).pipe(Effect.provide(BunServices.layer)), | ||
| 183 | ); | ||
| 184 | |||
| 185 | it.effect("tightens a pre-existing world-readable credential store on rewrite", () => | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-chmod-")); | ||
| 188 | process.env.EXECUTOR_DATA_DIR = dataDir; | ||
| 189 | const storePath = join(dataDir, "server-connections.json"); | ||
| 190 | |||
| 191 | try { | ||
| 192 | // A store left behind by an older version, world-readable. `mode` on | ||
| 193 | // write is ignored for an existing file, so only the chmod fixes this. | ||
| 194 | writeFileSync(storePath, JSON.stringify({ version: 1, profiles: [] })); | ||
| 195 | chmodSync(storePath, 0o644); | ||
| 196 | |||
| 197 | yield* upsertCliServerConnectionProfile({ | ||
| 198 | name: "remote", | ||
| 199 | connection: { | ||
| 200 | origin: "https://executor.example", | ||
| 201 | auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, | ||
| 202 | }, | ||
| 203 | makeDefault: true, | ||
| 204 | }); | ||
| 205 | |||
| 206 | expect(statSync(storePath).mode & 0o777).toBe(0o600); | ||
| 207 | } finally { | ||
| 208 | rmSync(dataDir, { recursive: true, force: true }); | ||
| 209 | } | ||
| 210 | }).pipe(Effect.provide(BunServices.layer)), | ||
| 211 | ); | ||
| 131 | }); | 212 | }); |
| @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines | |||
| 62 | }), | 62 | }), |
| 63 | ]); | 63 | ]); |
| 64 | 64 | ||
| 65 | const PersistedHeader = Schema.Struct({ | ||
| 66 | kind: Schema.Literal("env"), | ||
| 67 | name: Schema.String, | ||
| 68 | }); | ||
| 69 | |||
| 65 | const PersistedConnection = Schema.Struct({ | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | key: Schema.optional(Schema.String), | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | |||
| 69 | apiBaseUrl: Schema.optional(Schema.String), | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | displayName: Schema.optional(Schema.String), | 75 | displayName: Schema.optional(Schema.String), |
| 71 | auth: Schema.optional(PersistedAuth), | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | ||
| 72 | }); | 78 | }); |
| 73 | 79 | ||
| 74 | const PersistedProfile = Schema.Struct({ | 80 | const PersistedProfile = Schema.Struct({ |
| @@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines | |||
| 144 | const path = yield* Path.Path; | 150 | const path = yield* Path.Path; |
| 145 | const dataDir = resolveDataDir(path); | 151 | const dataDir = resolveDataDir(path); |
| 146 | yield* fs.makeDirectory(dataDir, { recursive: true }); | 152 | yield* fs.makeDirectory(dataDir, { recursive: true }); |
| 147 | yield* fs.writeFileString( | 153 | const storePath = serverConnectionStorePath(path); |
| 148 | serverConnectionStorePath(path), | 154 | // This store holds live credentials for a hosted server — a bearer token, or |
| 149 | serializeCliServerConnectionStore(store), | 155 | // an OAuth access token AND its long-lived refresh token — so create it |
| 150 | ); | 156 | // owner-only, exactly as the local-server manifest does for the sibling |
| 157 | // secret it keeps under `server-control/`. | ||
| 158 | // | ||
| 159 | // Both steps are needed, and the second matters more here than it does | ||
| 160 | // there. `mode` applies only when the file is CREATED, so it closes the | ||
| 161 | // window where a fresh store is briefly world-readable. The `chmod` covers | ||
| 162 | // overwriting a store that already exists with looser permissions — and this | ||
| 163 | // file is rewritten on every silent token refresh, so overwrite is the | ||
| 164 | // common path, not the rare one. | ||
| 165 | yield* fs.writeFileString(storePath, serializeCliServerConnectionStore(store), { | ||
| 166 | mode: 0o600, | ||
| 167 | }); | ||
| 168 | yield* fs.chmod(storePath, 0o600).pipe(Effect.ignore); | ||
| 151 | }); | 169 | }); |
| 152 | 170 | ||
| 153 | export const upsertCliServerConnectionProfile = (input: { | 171 | export const upsertCliServerConnectionProfile = (input: { |
| @@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines | |||
| 185 | expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); | 185 | expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); |
| 186 | }); | 186 | }); |
| 187 | 187 | ||
| 188 | it("preserves custom TLS trust paths in the supervised environment", () => { | ||
| 189 | const previousNodeExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; | ||
| 190 | const previousSslCertFile = process.env.SSL_CERT_FILE; | ||
| 191 | const previousSslCertDir = process.env.SSL_CERT_DIR; | ||
| 192 | process.env.NODE_EXTRA_CA_CERTS = "/Users/x/.certs/node-extra.pem"; | ||
| 193 | process.env.SSL_CERT_FILE = "/Users/x/.certs/combined.pem"; | ||
| 194 | process.env.SSL_CERT_DIR = "/Users/x/.certs"; | ||
| 195 | |||
| 196 | const wrapper = generateWindowsDaemonWrapper( | ||
| 197 | { | ||
| 198 | executablePath: "C:\\Program Files\\Executor\\executor.exe", | ||
| 199 | port: 4789, | ||
| 200 | version: "1.5.10", | ||
| 201 | }, | ||
| 202 | "C:\\Users\\x\\.executor", | ||
| 203 | "C:\\Users\\x\\.executor\\logs", | ||
| 204 | ); | ||
| 205 | |||
| 206 | if (previousNodeExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; | ||
| 207 | else process.env.NODE_EXTRA_CA_CERTS = previousNodeExtraCaCerts; | ||
| 208 | if (previousSslCertFile === undefined) delete process.env.SSL_CERT_FILE; | ||
| 209 | else process.env.SSL_CERT_FILE = previousSslCertFile; | ||
| 210 | if (previousSslCertDir === undefined) delete process.env.SSL_CERT_DIR; | ||
| 211 | else process.env.SSL_CERT_DIR = previousSslCertDir; | ||
| 212 | |||
| 213 | expect(wrapper).toContain('set "NODE_EXTRA_CA_CERTS=/Users/x/.certs/node-extra.pem"'); | ||
| 214 | expect(wrapper).toContain('set "SSL_CERT_FILE=/Users/x/.certs/combined.pem"'); | ||
| 215 | expect(wrapper).toContain('set "SSL_CERT_DIR=/Users/x/.certs"'); | ||
| 216 | }); | ||
| 217 | |||
| 188 | it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { | 218 | it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { |
| 189 | // A `"` in PATH would close the `set "PATH=..."` quote early and let a | 219 | // A `"` in PATH would close the `set "PATH=..."` quote early and let a |
| 190 | // `& cmd &` fragment run at boot as the user; strip it (illegal in a path | 220 | // `& cmd &` fragment run at boot as the user; strip it (illegal in a path |
| @@ -177,6 +177,17 @@ const serviceEnvironment = ( · 176 unmodified lines | |||
| 177 | "EXECUTOR_SENTRY_RELEASE", | 177 | "EXECUTOR_SENTRY_RELEASE", |
| 178 | "EXECUTOR_SENTRY_ENVIRONMENT", | 178 | "EXECUTOR_SENTRY_ENVIRONMENT", |
| 179 | "EXECUTOR_RUN_ID", | 179 | "EXECUTOR_RUN_ID", |
| 180 | // Analytics opt-out must survive into the supervised unit's minimal env, | ||
| 181 | // or an opted-out install would silently re-enable analytics under launchd. | ||
| 182 | "DO_NOT_TRACK", | ||
| 183 | "EXECUTOR_DISABLE_ANALYTICS", | ||
| 184 | // Preserve the caller's TLS trust configuration. Corporate/intercepting | ||
| 185 | // CAs commonly live outside the OS trust store; dropping these paths in a | ||
| 186 | // minimal launchd/systemd environment makes HTTPS integrations fail only | ||
| 187 | // after service installation while the same CLI call succeeds. | ||
| 188 | "NODE_EXTRA_CA_CERTS", | ||
| 189 | "SSL_CERT_FILE", | ||
| 190 | "SSL_CERT_DIR", | ||
| 180 | ] as const; | 191 | ] as const; |
| 181 | const passThrough = Object.fromEntries( | 192 | const passThrough = Object.fromEntries( |
| 182 | passThroughKeys.flatMap((key) => { | 193 | passThroughKeys.flatMap((key) => { |
| @@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines | |||
| 408 | return `return await tools.search(${JSON.stringify(payload)});`; | 408 | return `return await tools.search(${JSON.stringify(payload)});`; |
| 409 | }; | 409 | }; |
| 410 | 410 | ||
| 411 | export const buildListSourcesCode = (input: { query?: string; limit: number }): string => { | 411 | export const buildListIntegrationsCode = (input: { query?: string; limit: number }): string => { |
| 412 | const payload: Record<string, unknown> = { | 412 | const payload: Record<string, unknown> = { |
| 413 | limit: input.limit, | 413 | limit: input.limit, |
| 414 | }; | 414 | }; |
| 415 | if (input.query && input.query.trim().length > 0) { | 415 | if (input.query && input.query.trim().length > 0) { |
| 416 | payload.query = input.query; | 416 | payload.query = input.query; |
| 417 | } | 417 | } |
| 418 | return `return await tools.executor.sources.list(${JSON.stringify(payload)});`; | 418 | return `return await tools.executor.integrations.list(${JSON.stringify(payload)});`; |
| 419 | }; | 419 | }; |
| 420 | 420 | ||
| 421 | export const buildDescribeToolCode = (toolPath: string): string => | 421 | export const buildDescribeToolCode = (toolPath: string): string => |
| @@ -0,0 +1,59 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { join } from "node:path"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | WORKER_BUNDLER_DIRNAME, | ||
| 6 | WORKER_BUNDLER_RUNTIME_FILES, | ||
| 7 | missingWorkerBundlerFiles, | ||
| 8 | } from "./worker-bundler-artifact"; | ||
| 9 | |||
| 10 | const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME); | ||
| 11 | |||
| 12 | /** An existence probe that reports only the given POSIX-relative paths. */ | ||
| 13 | const staged = | ||
| 14 | (...relatives: readonly string[]) => | ||
| 15 | (path: string): boolean => | ||
| 16 | relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/"))); | ||
| 17 | |||
| 18 | describe("missingWorkerBundlerFiles", () => { | ||
| 19 | it("reports nothing when the full artifact is staged", () => { | ||
| 20 | expect(missingWorkerBundlerFiles(STAGED_DIR, staged(...WORKER_BUNDLER_RUNTIME_FILES))).toEqual( | ||
| 21 | [], | ||
| 22 | ); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("reports every file when the directory is empty", () => { | ||
| 26 | expect(missingWorkerBundlerFiles(STAGED_DIR, () => false)).toEqual([ | ||
| 27 | ...WORKER_BUNDLER_RUNTIME_FILES, | ||
| 28 | ]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | // The regression this whole seam exists for: the packaged daemon reads the | ||
| 32 | // wasm from disk beside the entry. A copy that brought the JS but dropped the | ||
| 33 | // wasm used to pass the build and fail on the user's machine at startup. | ||
| 34 | it("reports the esbuild wasm when only the JS entrypoints are staged", () => { | ||
| 35 | expect( | ||
| 36 | missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/index.bundled.js")), | ||
| 37 | ).toEqual(["dist/esbuild.wasm"]); | ||
| 38 | }); | ||
| 39 | |||
| 40 | // `index.bundled.js` is the file consumers load — the package's own | ||
| 41 | // `dist/index.js` still has bare imports nothing can resolve inside the | ||
| 42 | // compiled binary. Staging index.js alone is not enough. | ||
| 43 | it("reports the packed entry when only the unbundled package entry is staged", () => { | ||
| 44 | expect( | ||
| 45 | missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/esbuild.wasm")), | ||
| 46 | ).toEqual(["dist/index.bundled.js"]); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("names files relative to the staged directory so errors are quotable", () => { | ||
| 50 | for (const relative of WORKER_BUNDLER_RUNTIME_FILES) { | ||
| 51 | expect(relative.startsWith("dist/")).toBe(true); | ||
| 52 | } | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("requires the packed entry and the wasm, not just the package entry", () => { | ||
| 56 | expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/index.bundled.js"); | ||
| 57 | expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/esbuild.wasm"); | ||
| 58 | }); | ||
| 59 | }); | ||
| @@ -0,0 +1,55 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The packed `@cloudflare/worker-bundler` artifact that ships NEXT TO the | ||
| 3 | // compiled binary. | ||
| 4 | // | ||
| 5 | // `bun build --compile` cannot embed a package that is only ever addressed by | ||
| 6 | // bare specifier at runtime: bunfs has no `node_modules`, so an | ||
| 7 | // `import("@cloudflare/worker-bundler")` evaluated inside the binary resolves | ||
| 8 | // against the virtual root and throws | ||
| 9 | // ResolveMessage: Cannot find module '@cloudflare/worker-bundler' | ||
| 10 | // The build therefore copies the package's `dist/` next to the executable and | ||
| 11 | // `native-bindings.ts` publishes its absolute path as | ||
| 12 | // EXECUTOR_WORKER_BUNDLER_DIR. Consumers must load from that directory; a bare | ||
| 13 | // specifier is unresolvable in the packaged daemon by construction. | ||
| 14 | // | ||
| 15 | // This module is the single source of truth for which files that contract | ||
| 16 | // needs, so the build-time staging check and the runtime lookup cannot drift. | ||
| 17 | // They previously did: the build wrote `dist/index.bundled.js` (the file | ||
| 18 | // consumers actually load) while the runtime check only looked for | ||
| 19 | // `dist/index.js` and `dist/esbuild.wasm`. That drift fails OPEN — an | ||
| 20 | // incomplete staged copy passes the build, the runtime silently declines to | ||
| 21 | // publish the env var, and the consumer falls through to the bare specifier. | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | import { join } from "node:path"; | ||
| 25 | |||
| 26 | /** Directory name staged beside the executable, and looked up from execDir. */ | ||
| 27 | export const WORKER_BUNDLER_DIRNAME = "worker-bundler"; | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Files the colocated copy must contain to be usable. | ||
| 31 | * | ||
| 32 | * `index.bundled.js` is the entrypoint consumers load (the build packs it with | ||
| 33 | * esbuild so it has no bare-specifier imports of its own); `esbuild.wasm` is | ||
| 34 | * read at runtime beside it; `index.js` is the package's own entry, kept so the | ||
| 35 | * staged directory stays a faithful copy rather than a lookalike. | ||
| 36 | */ | ||
| 37 | export const WORKER_BUNDLER_RUNTIME_FILES = [ | ||
| 38 | "dist/index.js", | ||
| 39 | "dist/index.bundled.js", | ||
| 40 | "dist/esbuild.wasm", | ||
| 41 | ] as const; | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Which required files are absent from a staged worker-bundler directory. | ||
| 45 | * | ||
| 46 | * Takes the existence probe as a parameter so the build script, the runtime | ||
| 47 | * bootstrap, and tests all agree on the requirement without touching a real | ||
| 48 | * filesystem. Returns the POSIX-relative names, which is what error messages | ||
| 49 | * should quote. | ||
| 50 | */ | ||
| 51 | export const missingWorkerBundlerFiles = ( | ||
| 52 | dir: string, | ||
| 53 | exists: (path: string) => boolean, | ||
| 54 | ): readonly string[] => | ||
| 55 | WORKER_BUNDLER_RUNTIME_FILES.filter((relative) => !exists(join(dir, ...relative.split("/")))); | ||
| @@ -8,7 +8,7 @@ · 7 unmodified lines | |||
| 8 | "skipLibCheck": true, | 8 | "skipLibCheck": true, |
| 9 | "noEmit": true, | 9 | "noEmit": true, |
| 10 | "declaration": true, | 10 | "declaration": true, |
| 11 | "types": ["bun-types"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": ["src", "bin"] | 13 | "include": ["src", "bin"] |
| 14 | } | 14 | } |
| @@ -1,5 +1,298 @@ | |||
| 1 | # @executor-js/cloud | 1 | # @executor-js/cloud |
| 2 | 2 | ||
| 3 | ## 1.4.61 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`2bdbedf`](https://github.com/UsefulSoftwareCo/executor/commit/2bdbedf257f54d7c209e8c856c618174c10d6bb3), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]: | ||
| 8 | - @executor-js/plugin-mcp@1.6.0 | ||
| 9 | - @executor-js/plugin-openapi@1.6.0 | ||
| 10 | - @executor-js/sdk@1.6.0 | ||
| 11 | - @executor-js/react@1.4.63 | ||
| 12 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 13 | - @executor-js/api@1.4.63 | ||
| 14 | - @executor-js/execution@1.6.0 | ||
| 15 | - @executor-js/vite-plugin@0.0.60 | ||
| 16 | - @executor-js/cloudflare@0.0.42 | ||
| 17 | - @executor-js/host-mcp@1.4.4 | ||
| 18 | - @executor-js/mcp-apps-shell@1.4.11 | ||
| 19 | - @executor-js/plugin-graphql@1.6.0 | ||
| 20 | - @executor-js/plugin-toolkits@1.5.35 | ||
| 21 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 22 | - @executor-js/runtime-quickjs@1.6.0 | ||
| 23 | |||
| 24 | ## 1.4.60 | ||
| 25 | |||
| 26 | ### Patch Changes | ||
| 27 | |||
| 28 | - Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`86c68af`](https://github.com/UsefulSoftwareCo/executor/commit/86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: | ||
| 29 | - @executor-js/sdk@1.5.42 | ||
| 30 | - @executor-js/cloudflare@0.0.41 | ||
| 31 | - @executor-js/plugin-openapi@1.5.42 | ||
| 32 | - @executor-js/plugin-mcp@1.5.42 | ||
| 33 | - @executor-js/api@1.4.62 | ||
| 34 | - @executor-js/execution@1.5.42 | ||
| 35 | - @executor-js/vite-plugin@0.0.59 | ||
| 36 | - @executor-js/host-mcp@1.4.4 | ||
| 37 | - @executor-js/mcp-apps-shell@1.4.10 | ||
| 38 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 39 | - @executor-js/plugin-graphql@1.5.42 | ||
| 40 | - @executor-js/plugin-toolkits@1.5.34 | ||
| 41 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 42 | - @executor-js/react@1.4.62 | ||
| 43 | - @executor-js/runtime-quickjs@1.5.42 | ||
| 44 | |||
| 45 | ## 1.4.59 | ||
| 46 | |||
| 47 | ### Patch Changes | ||
| 48 | |||
| 49 | - Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: | ||
| 50 | - @executor-js/sdk@1.5.41 | ||
| 51 | - @executor-js/plugin-mcp@1.5.41 | ||
| 52 | - @executor-js/api@1.4.61 | ||
| 53 | - @executor-js/execution@1.5.41 | ||
| 54 | - @executor-js/vite-plugin@0.0.58 | ||
| 55 | - @executor-js/cloudflare@0.0.40 | ||
| 56 | - @executor-js/host-mcp@1.4.4 | ||
| 57 | - @executor-js/mcp-apps-shell@1.4.9 | ||
| 58 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 59 | - @executor-js/plugin-graphql@1.5.41 | ||
| 60 | - @executor-js/plugin-openapi@1.5.41 | ||
| 61 | - @executor-js/plugin-toolkits@1.5.33 | ||
| 62 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 63 | - @executor-js/react@1.4.61 | ||
| 64 | - @executor-js/runtime-quickjs@1.5.41 | ||
| 65 | |||
| 66 | ## 1.4.58 | ||
| 67 | |||
| 68 | ### Patch Changes | ||
| 69 | |||
| 70 | - Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: | ||
| 71 | - @executor-js/sdk@1.5.40 | ||
| 72 | - @executor-js/api@1.4.60 | ||
| 73 | - @executor-js/execution@1.5.40 | ||
| 74 | - @executor-js/vite-plugin@0.0.57 | ||
| 75 | - @executor-js/cloudflare@0.0.39 | ||
| 76 | - @executor-js/host-mcp@1.4.4 | ||
| 77 | - @executor-js/mcp-apps-shell@1.4.8 | ||
| 78 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 79 | - @executor-js/plugin-graphql@1.5.40 | ||
| 80 | - @executor-js/plugin-mcp@1.5.40 | ||
| 81 | - @executor-js/plugin-openapi@1.5.40 | ||
| 82 | - @executor-js/plugin-toolkits@1.5.32 | ||
| 83 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 84 | - @executor-js/react@1.4.60 | ||
| 85 | - @executor-js/runtime-quickjs@1.5.40 | ||
| 86 | |||
| 87 | ## 1.4.57 | ||
| 88 | |||
| 89 | ### Patch Changes | ||
| 90 | |||
| 91 | - Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: | ||
| 92 | - @executor-js/sdk@1.5.39 | ||
| 93 | - @executor-js/api@1.4.59 | ||
| 94 | - @executor-js/execution@1.5.39 | ||
| 95 | - @executor-js/vite-plugin@0.0.56 | ||
| 96 | - @executor-js/cloudflare@0.0.38 | ||
| 97 | - @executor-js/host-mcp@1.4.4 | ||
| 98 | - @executor-js/mcp-apps-shell@1.4.7 | ||
| 99 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 100 | - @executor-js/plugin-graphql@1.5.39 | ||
| 101 | - @executor-js/plugin-mcp@1.5.39 | ||
| 102 | - @executor-js/plugin-openapi@1.5.39 | ||
| 103 | - @executor-js/plugin-toolkits@1.5.31 | ||
| 104 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 105 | - @executor-js/react@1.4.59 | ||
| 106 | - @executor-js/runtime-quickjs@1.5.39 | ||
| 107 | |||
| 108 | ## 1.4.56 | ||
| 109 | |||
| 110 | ### Patch Changes | ||
| 111 | |||
| 112 | - Updated dependencies [[`df01d91`](https://github.com/UsefulSoftwareCo/executor/commit/df01d9197e7b4fca9bd0adaca0705a80435e188c), [`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4), [`1de85fc`](https://github.com/UsefulSoftwareCo/executor/commit/1de85fc0201c0c23c0e71e003c49228d406af6c8)]: | ||
| 113 | - @executor-js/plugin-openapi@1.5.38 | ||
| 114 | - @executor-js/sdk@1.5.38 | ||
| 115 | - @executor-js/react@1.4.58 | ||
| 116 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 117 | - @executor-js/api@1.4.58 | ||
| 118 | - @executor-js/execution@1.5.38 | ||
| 119 | - @executor-js/vite-plugin@0.0.55 | ||
| 120 | - @executor-js/cloudflare@0.0.37 | ||
| 121 | - @executor-js/host-mcp@1.4.4 | ||
| 122 | - @executor-js/mcp-apps-shell@1.4.6 | ||
| 123 | - @executor-js/plugin-graphql@1.5.38 | ||
| 124 | - @executor-js/plugin-mcp@1.5.38 | ||
| 125 | - @executor-js/plugin-toolkits@1.5.30 | ||
| 126 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 127 | - @executor-js/runtime-quickjs@1.5.38 | ||
| 128 | |||
| 129 | ## 1.4.55 | ||
| 130 | |||
| 131 | ### Patch Changes | ||
| 132 | |||
| 133 | - Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: | ||
| 134 | - @executor-js/sdk@1.5.37 | ||
| 135 | - @executor-js/api@1.4.57 | ||
| 136 | - @executor-js/execution@1.5.37 | ||
| 137 | - @executor-js/vite-plugin@0.0.54 | ||
| 138 | - @executor-js/cloudflare@0.0.36 | ||
| 139 | - @executor-js/host-mcp@1.4.4 | ||
| 140 | - @executor-js/mcp-apps-shell@1.4.5 | ||
| 141 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 142 | - @executor-js/plugin-graphql@1.5.37 | ||
| 143 | - @executor-js/plugin-mcp@1.5.37 | ||
| 144 | - @executor-js/plugin-openapi@1.5.37 | ||
| 145 | - @executor-js/plugin-toolkits@1.5.29 | ||
| 146 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 147 | - @executor-js/react@1.4.57 | ||
| 148 | - @executor-js/runtime-quickjs@1.5.37 | ||
| 149 | |||
| 150 | ## 1.4.54 | ||
| 151 | |||
| 152 | ### Patch Changes | ||
| 153 | |||
| 154 | - Updated dependencies []: | ||
| 155 | - @executor-js/sdk@1.5.36 | ||
| 156 | - @executor-js/runtime-quickjs@1.5.36 | ||
| 157 | - @executor-js/execution@1.5.36 | ||
| 158 | - @executor-js/plugin-graphql@1.5.36 | ||
| 159 | - @executor-js/plugin-mcp@1.5.36 | ||
| 160 | - @executor-js/plugin-openapi@1.5.36 | ||
| 161 | - @executor-js/api@1.4.56 | ||
| 162 | - @executor-js/vite-plugin@0.0.53 | ||
| 163 | - @executor-js/cloudflare@0.0.35 | ||
| 164 | - @executor-js/host-mcp@1.4.4 | ||
| 165 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 166 | - @executor-js/plugin-toolkits@1.5.28 | ||
| 167 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 168 | - @executor-js/react@1.4.56 | ||
| 169 | |||
| 170 | ## 1.4.53 | ||
| 171 | |||
| 172 | ### Patch Changes | ||
| 173 | |||
| 174 | - Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`af95edb`](https://github.com/UsefulSoftwareCo/executor/commit/af95edbb0bbde544bb1f4c6e18e9d64a2bcab0f8), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]: | ||
| 175 | - @executor-js/sdk@1.5.35 | ||
| 176 | - @executor-js/plugin-mcp@1.5.35 | ||
| 177 | - @executor-js/runtime-quickjs@1.5.35 | ||
| 178 | - @executor-js/api@1.4.55 | ||
| 179 | - @executor-js/execution@1.5.35 | ||
| 180 | - @executor-js/vite-plugin@0.0.52 | ||
| 181 | - @executor-js/cloudflare@0.0.34 | ||
| 182 | - @executor-js/host-mcp@1.4.4 | ||
| 183 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 184 | - @executor-js/plugin-apps@0.1.6 | ||
| 185 | - @executor-js/plugin-graphql@1.5.35 | ||
| 186 | - @executor-js/plugin-openapi@1.5.35 | ||
| 187 | - @executor-js/plugin-toolkits@1.5.27 | ||
| 188 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 189 | - @executor-js/react@1.4.55 | ||
| 190 | |||
| 191 | ## 1.4.52 | ||
| 192 | |||
| 193 | ### Patch Changes | ||
| 194 | |||
| 195 | - Updated dependencies [[`e2712db`](https://github.com/UsefulSoftwareCo/executor/commit/e2712dbff98145c5c340832ffbdcb21113b9dd78), [`a86cc4e`](https://github.com/UsefulSoftwareCo/executor/commit/a86cc4e6d0252c90834f40ee09837d8a19cab7fe), [`7207347`](https://github.com/UsefulSoftwareCo/executor/commit/720734756a70b1b4f1564bdf82dc4118e5de2b76), [`0c4e9b4`](https://github.com/UsefulSoftwareCo/executor/commit/0c4e9b49fecb35ad71c92a464c3ea01131ff9d6f), [`171de20`](https://github.com/UsefulSoftwareCo/executor/commit/171de204725d10405c693549febc3a1cce2c24d8)]: | ||
| 196 | - @executor-js/sdk@1.5.34 | ||
| 197 | - @executor-js/execution@1.5.34 | ||
| 198 | - @executor-js/plugin-openapi@1.5.34 | ||
| 199 | - @executor-js/api@1.4.54 | ||
| 200 | - @executor-js/vite-plugin@0.0.51 | ||
| 201 | - @executor-js/cloudflare@0.0.33 | ||
| 202 | - @executor-js/host-mcp@1.4.4 | ||
| 203 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 204 | - @executor-js/plugin-apps@0.1.5 | ||
| 205 | - @executor-js/plugin-graphql@1.5.34 | ||
| 206 | - @executor-js/plugin-mcp@1.5.34 | ||
| 207 | - @executor-js/plugin-toolkits@1.5.26 | ||
| 208 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 209 | - @executor-js/react@1.4.54 | ||
| 210 | - @executor-js/runtime-quickjs@1.5.34 | ||
| 211 | |||
| 212 | ## 1.4.51 | ||
| 213 | |||
| 214 | ### Patch Changes | ||
| 215 | |||
| 216 | - Updated dependencies []: | ||
| 217 | - @executor-js/sdk@1.5.33 | ||
| 218 | - @executor-js/runtime-quickjs@1.5.33 | ||
| 219 | - @executor-js/execution@1.5.33 | ||
| 220 | - @executor-js/plugin-graphql@1.5.33 | ||
| 221 | - @executor-js/plugin-mcp@1.5.33 | ||
| 222 | - @executor-js/plugin-openapi@1.5.33 | ||
| 223 | - @executor-js/api@1.4.53 | ||
| 224 | - @executor-js/vite-plugin@0.0.50 | ||
| 225 | - @executor-js/cloudflare@0.0.32 | ||
| 226 | - @executor-js/host-mcp@1.4.4 | ||
| 227 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 228 | - @executor-js/plugin-apps@0.1.4 | ||
| 229 | - @executor-js/plugin-toolkits@1.5.25 | ||
| 230 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 231 | - @executor-js/react@1.4.53 | ||
| 232 | |||
| 233 | ## 1.4.50 | ||
| 234 | |||
| 235 | ### Patch Changes | ||
| 236 | |||
| 237 | - Updated dependencies []: | ||
| 238 | - @executor-js/sdk@1.5.32 | ||
| 239 | - @executor-js/runtime-quickjs@1.5.32 | ||
| 240 | - @executor-js/execution@1.5.32 | ||
| 241 | - @executor-js/plugin-graphql@1.5.32 | ||
| 242 | - @executor-js/plugin-mcp@1.5.32 | ||
| 243 | - @executor-js/plugin-openapi@1.5.32 | ||
| 244 | - @executor-js/api@1.4.52 | ||
| 245 | - @executor-js/vite-plugin@0.0.49 | ||
| 246 | - @executor-js/cloudflare@0.0.31 | ||
| 247 | - @executor-js/host-mcp@1.4.4 | ||
| 248 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 249 | - @executor-js/plugin-apps@0.1.3 | ||
| 250 | - @executor-js/plugin-toolkits@1.5.24 | ||
| 251 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 252 | - @executor-js/react@1.4.52 | ||
| 253 | |||
| 254 | ## 1.4.49 | ||
| 255 | |||
| 256 | ### Patch Changes | ||
| 257 | |||
| 258 | - Updated dependencies [[`9e38928`](https://github.com/UsefulSoftwareCo/executor/commit/9e38928f0fda9032b64b26990270c5d2b6690d13)]: | ||
| 259 | - @executor-js/plugin-openapi@1.5.31 | ||
| 260 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 261 | - @executor-js/sdk@1.5.31 | ||
| 262 | - @executor-js/runtime-quickjs@1.5.31 | ||
| 263 | - @executor-js/execution@1.5.31 | ||
| 264 | - @executor-js/plugin-graphql@1.5.31 | ||
| 265 | - @executor-js/plugin-mcp@1.5.31 | ||
| 266 | - @executor-js/api@1.4.51 | ||
| 267 | - @executor-js/vite-plugin@0.0.48 | ||
| 268 | - @executor-js/cloudflare@0.0.30 | ||
| 269 | - @executor-js/host-mcp@1.4.4 | ||
| 270 | - @executor-js/plugin-apps@0.1.2 | ||
| 271 | - @executor-js/plugin-toolkits@1.5.23 | ||
| 272 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 273 | - @executor-js/react@1.4.51 | ||
| 274 | |||
| 275 | ## 1.4.48 | ||
| 276 | |||
| 277 | ### Patch Changes | ||
| 278 | |||
| 279 | - Updated dependencies []: | ||
| 280 | - @executor-js/sdk@1.5.30 | ||
| 281 | - @executor-js/runtime-quickjs@1.5.30 | ||
| 282 | - @executor-js/execution@1.5.30 | ||
| 283 | - @executor-js/plugin-graphql@1.5.30 | ||
| 284 | - @executor-js/plugin-mcp@1.5.30 | ||
| 285 | - @executor-js/plugin-openapi@1.5.30 | ||
| 286 | - @executor-js/api@1.4.50 | ||
| 287 | - @executor-js/vite-plugin@0.0.47 | ||
| 288 | - @executor-js/cloudflare@0.0.29 | ||
| 289 | - @executor-js/host-mcp@1.4.4 | ||
| 290 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 291 | - @executor-js/plugin-apps@0.1.1 | ||
| 292 | - @executor-js/plugin-toolkits@1.5.22 | ||
| 293 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 294 | - @executor-js/react@1.4.50 | ||
| 295 | |||
| 3 | ## 1.4.47 | 296 | ## 1.4.47 |
| 4 | 297 | ||
| 5 | ### Patch Changes | 298 | ### Patch Changes |
| @@ -0,0 +1,23 @@ | |||
| 1 | -- Extraction used to persist an NDJSON operation's PER-LINE response schema | ||
| 2 | -- as the tool's output schema, while the invoke path returns an ARRAY of | ||
| 3 | -- parsed lines, so describe previews promised a single object invocations | ||
| 4 | -- never returned. The producer now wraps those schemas in an array. Persisted | ||
| 5 | -- `tool` rows can't be recognized as NDJSON-derived from their schema alone, | ||
| 6 | -- but the stored operation bindings kept the response content type, so the | ||
| 7 | -- affected connections are findable: stale-mark `tools_synced_at` and the | ||
| 8 | -- next read rebuilds their tool rows through the fixed producer. | ||
| 9 | -- | ||
| 10 | -- Effectively idempotent: re-running re-marks the same connections, and a | ||
| 11 | -- rebuild from unchanged bindings writes identical rows. | ||
| 12 | UPDATE "connection" c | ||
| 13 | SET "tools_synced_at" = NULL | ||
| 14 | FROM ( | ||
| 15 | SELECT DISTINCT ps."tenant", ps."data"::jsonb ->> 'integration' AS integration | ||
| 16 | FROM "plugin_storage" ps | ||
| 17 | WHERE ps."collection" = 'operation' | ||
| 18 | AND (ps."data"::text LIKE '%application/stream+json%' | ||
| 19 | OR ps."data"::text LIKE '%application/x-ndjson%' | ||
| 20 | OR ps."data"::text LIKE '%application/jsonl%') | ||
| 21 | ) ndjson | ||
| 22 | WHERE c."tenant" = ndjson."tenant" | ||
| 23 | AND c."integration" = ndjson."integration"; | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "oauth_client" ADD COLUMN "origin_redirect_uri" text; | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | CREATE TABLE "subject" ( | ||
| 2 | "external_id" varchar(255) NOT NULL, | ||
| 3 | "created_at" timestamp NOT NULL, | ||
| 4 | "last_seen_at" bigint, | ||
| 5 | "status" text, | ||
| 6 | "row_id" varchar(255) PRIMARY KEY NOT NULL, | ||
| 7 | "tenant" varchar(255) NOT NULL | ||
| 8 | ); | ||
| 9 | --> statement-breakpoint | ||
| 10 | CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id"); | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | CREATE TABLE "artifact" ( | ||
| 2 | "id" varchar(255) NOT NULL, | ||
| 3 | "title" text NOT NULL, | ||
| 4 | "description" text, | ||
| 5 | "code" text NOT NULL, | ||
| 6 | "created_at" timestamp NOT NULL, | ||
| 7 | "updated_at" timestamp NOT NULL, | ||
| 8 | "row_id" varchar(255) PRIMARY KEY NOT NULL, | ||
| 9 | "tenant" varchar(255) NOT NULL, | ||
| 10 | "owner" varchar(255) NOT NULL, | ||
| 11 | "subject" varchar(255) NOT NULL | ||
| 12 | ); | ||
| 13 | --> statement-breakpoint | ||
| 14 | CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "artifact" ADD COLUMN "bindings" json; | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "artifact" ADD COLUMN "preview" text; | ||
| @@ -0,0 +1,1290 @@ | |||
| 1 | { | ||
| 2 | "id": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6", | ||
| 3 | "prevId": "b07ddbda-be11-4679-9f00-16d1b3b5e879", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config_revised_at": { | ||
| 535 | "name": "config_revised_at", | ||
| 536 | "type": "bigint", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "config": { | ||
| 541 | "name": "config", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "health_check": { | ||
| 547 | "name": "health_check", | ||
| 548 | "type": "json", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "created_at": { | ||
| 686 | "name": "created_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "row_id": { | ||
| 692 | "name": "row_id", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": true, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "tenant": { | ||
| 698 | "name": "tenant", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": false, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "owner": { | ||
| 704 | "name": "owner", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "subject": { | ||
| 710 | "name": "subject", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | } | ||
| 715 | }, | ||
| 716 | "indexes": { | ||
| 717 | "oauth_client_uidx": { | ||
| 718 | "name": "oauth_client_uidx", | ||
| 719 | "columns": [ | ||
| 720 | { | ||
| 721 | "expression": "tenant", | ||
| 722 | "isExpression": false, | ||
| 723 | "asc": true, | ||
| 724 | "nulls": "last" | ||
| 725 | }, | ||
| 726 | { | ||
| 727 | "expression": "owner", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "subject", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "slug", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | } | ||
| 744 | ], | ||
| 745 | "isUnique": true, | ||
| 746 | "concurrently": false, | ||
| 747 | "method": "btree", | ||
| 748 | "with": {} | ||
| 749 | } | ||
| 750 | }, | ||
| 751 | "foreignKeys": {}, | ||
| 752 | "compositePrimaryKeys": {}, | ||
| 753 | "uniqueConstraints": {}, | ||
| 754 | "policies": {}, | ||
| 755 | "checkConstraints": {}, | ||
| 756 | "isRLSEnabled": false | ||
| 757 | }, | ||
| 758 | "public.oauth_session": { | ||
| 759 | "name": "oauth_session", | ||
| 760 | "schema": "", | ||
| 761 | "columns": { | ||
| 762 | "state": { | ||
| 763 | "name": "state", | ||
| 764 | "type": "varchar(255)", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_slug": { | ||
| 769 | "name": "client_slug", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "integration": { | ||
| 775 | "name": "integration", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "name": { | ||
| 781 | "name": "name", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "template": { | ||
| 787 | "name": "template", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "redirect_url": { | ||
| 793 | "name": "redirect_url", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "pkce_verifier": { | ||
| 799 | "name": "pkce_verifier", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "identity_label": { | ||
| 805 | "name": "identity_label", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "payload": { | ||
| 811 | "name": "payload", | ||
| 812 | "type": "json", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "expires_at": { | ||
| 817 | "name": "expires_at", | ||
| 818 | "type": "bigint", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "created_at": { | ||
| 823 | "name": "created_at", | ||
| 824 | "type": "timestamp", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "row_id": { | ||
| 829 | "name": "row_id", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": true, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "tenant": { | ||
| 835 | "name": "tenant", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": false, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "owner": { | ||
| 841 | "name": "owner", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "subject": { | ||
| 847 | "name": "subject", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | } | ||
| 852 | }, | ||
| 853 | "indexes": { | ||
| 854 | "oauth_session_uidx": { | ||
| 855 | "name": "oauth_session_uidx", | ||
| 856 | "columns": [ | ||
| 857 | { | ||
| 858 | "expression": "tenant", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | }, | ||
| 863 | { | ||
| 864 | "expression": "state", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | } | ||
| 869 | ], | ||
| 870 | "isUnique": true, | ||
| 871 | "concurrently": false, | ||
| 872 | "method": "btree", | ||
| 873 | "with": {} | ||
| 874 | } | ||
| 875 | }, | ||
| 876 | "foreignKeys": {}, | ||
| 877 | "compositePrimaryKeys": {}, | ||
| 878 | "uniqueConstraints": {}, | ||
| 879 | "policies": {}, | ||
| 880 | "checkConstraints": {}, | ||
| 881 | "isRLSEnabled": false | ||
| 882 | }, | ||
| 883 | "public.plugin_storage": { | ||
| 884 | "name": "plugin_storage", | ||
| 885 | "schema": "", | ||
| 886 | "columns": { | ||
| 887 | "plugin_id": { | ||
| 888 | "name": "plugin_id", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "collection": { | ||
| 894 | "name": "collection", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "key": { | ||
| 900 | "name": "key", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "data": { | ||
| 906 | "name": "data", | ||
| 907 | "type": "json", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "created_at": { | ||
| 912 | "name": "created_at", | ||
| 913 | "type": "timestamp", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "updated_at": { | ||
| 918 | "name": "updated_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "row_id": { | ||
| 924 | "name": "row_id", | ||
| 925 | "type": "varchar(255)", | ||
| 926 | "primaryKey": true, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "tenant": { | ||
| 930 | "name": "tenant", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "owner": { | ||
| 936 | "name": "owner", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "subject": { | ||
| 942 | "name": "subject", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | } | ||
| 947 | }, | ||
| 948 | "indexes": { | ||
| 949 | "plugin_storage_uidx": { | ||
| 950 | "name": "plugin_storage_uidx", | ||
| 951 | "columns": [ | ||
| 952 | { | ||
| 953 | "expression": "tenant", | ||
| 954 | "isExpression": false, | ||
| 955 | "asc": true, | ||
| 956 | "nulls": "last" | ||
| 957 | }, | ||
| 958 | { | ||
| 959 | "expression": "owner", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "subject", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "plugin_id", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "collection", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "key", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | } | ||
| 988 | ], | ||
| 989 | "isUnique": true, | ||
| 990 | "concurrently": false, | ||
| 991 | "method": "btree", | ||
| 992 | "with": {} | ||
| 993 | } | ||
| 994 | }, | ||
| 995 | "foreignKeys": {}, | ||
| 996 | "compositePrimaryKeys": {}, | ||
| 997 | "uniqueConstraints": {}, | ||
| 998 | "policies": {}, | ||
| 999 | "checkConstraints": {}, | ||
| 1000 | "isRLSEnabled": false | ||
| 1001 | }, | ||
| 1002 | "public.private_executor_cloud_settings": { | ||
| 1003 | "name": "private_executor_cloud_settings", | ||
| 1004 | "schema": "", | ||
| 1005 | "columns": { | ||
| 1006 | "id": { | ||
| 1007 | "name": "id", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": true, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "version": { | ||
| 1013 | "name": "version", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true, | ||
| 1017 | "default": "'1.0.0'" | ||
| 1018 | } | ||
| 1019 | }, | ||
| 1020 | "indexes": {}, | ||
| 1021 | "foreignKeys": {}, | ||
| 1022 | "compositePrimaryKeys": {}, | ||
| 1023 | "uniqueConstraints": {}, | ||
| 1024 | "policies": {}, | ||
| 1025 | "checkConstraints": {}, | ||
| 1026 | "isRLSEnabled": false | ||
| 1027 | }, | ||
| 1028 | "public.tool": { | ||
| 1029 | "name": "tool", | ||
| 1030 | "schema": "", | ||
| 1031 | "columns": { | ||
| 1032 | "integration": { | ||
| 1033 | "name": "integration", | ||
| 1034 | "type": "varchar(255)", | ||
| 1035 | "primaryKey": false, | ||
| 1036 | "notNull": true | ||
| 1037 | }, | ||
| 1038 | "connection": { | ||
| 1039 | "name": "connection", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "plugin_id": { | ||
| 1045 | "name": "plugin_id", | ||
| 1046 | "type": "text", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "name": { | ||
| 1051 | "name": "name", | ||
| 1052 | "type": "varchar(255)", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "description": { | ||
| 1057 | "name": "description", | ||
| 1058 | "type": "text", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "input_schema": { | ||
| 1063 | "name": "input_schema", | ||
| 1064 | "type": "json", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": false | ||
| 1067 | }, | ||
| 1068 | "output_schema": { | ||
| 1069 | "name": "output_schema", | ||
| 1070 | "type": "json", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": false | ||
| 1073 | }, | ||
| 1074 | "annotations": { | ||
| 1075 | "name": "annotations", | ||
| 1076 | "type": "json", | ||
| 1077 | "primaryKey": false, | ||
| 1078 | "notNull": false | ||
| 1079 | }, | ||
| 1080 | "created_at": { | ||
| 1081 | "name": "created_at", | ||
| 1082 | "type": "timestamp", | ||
| 1083 | "primaryKey": false, | ||
| 1084 | "notNull": true | ||
| 1085 | }, | ||
| 1086 | "updated_at": { | ||
| 1087 | "name": "updated_at", | ||
| 1088 | "type": "timestamp", | ||
| 1089 | "primaryKey": false, | ||
| 1090 | "notNull": true | ||
| 1091 | }, | ||
| 1092 | "row_id": { | ||
| 1093 | "name": "row_id", | ||
| 1094 | "type": "varchar(255)", | ||
| 1095 | "primaryKey": true, | ||
| 1096 | "notNull": true | ||
| 1097 | }, | ||
| 1098 | "tenant": { | ||
| 1099 | "name": "tenant", | ||
| 1100 | "type": "varchar(255)", | ||
| 1101 | "primaryKey": false, | ||
| 1102 | "notNull": true | ||
| 1103 | }, | ||
| 1104 | "owner": { | ||
| 1105 | "name": "owner", | ||
| 1106 | "type": "varchar(255)", | ||
| 1107 | "primaryKey": false, | ||
| 1108 | "notNull": true | ||
| 1109 | }, | ||
| 1110 | "subject": { | ||
| 1111 | "name": "subject", | ||
| 1112 | "type": "varchar(255)", | ||
| 1113 | "primaryKey": false, | ||
| 1114 | "notNull": true | ||
| 1115 | } | ||
| 1116 | }, | ||
| 1117 | "indexes": { | ||
| 1118 | "tool_uidx": { | ||
| 1119 | "name": "tool_uidx", | ||
| 1120 | "columns": [ | ||
| 1121 | { | ||
| 1122 | "expression": "tenant", | ||
| 1123 | "isExpression": false, | ||
| 1124 | "asc": true, | ||
| 1125 | "nulls": "last" | ||
| 1126 | }, | ||
| 1127 | { | ||
| 1128 | "expression": "owner", | ||
| 1129 | "isExpression": false, | ||
| 1130 | "asc": true, | ||
| 1131 | "nulls": "last" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "expression": "subject", | ||
| 1135 | "isExpression": false, | ||
| 1136 | "asc": true, | ||
| 1137 | "nulls": "last" | ||
| 1138 | }, | ||
| 1139 | { | ||
| 1140 | "expression": "integration", | ||
| 1141 | "isExpression": false, | ||
| 1142 | "asc": true, | ||
| 1143 | "nulls": "last" | ||
| 1144 | }, | ||
| 1145 | { | ||
| 1146 | "expression": "connection", | ||
| 1147 | "isExpression": false, | ||
| 1148 | "asc": true, | ||
| 1149 | "nulls": "last" | ||
| 1150 | }, | ||
| 1151 | { | ||
| 1152 | "expression": "name", | ||
| 1153 | "isExpression": false, | ||
| 1154 | "asc": true, | ||
| 1155 | "nulls": "last" | ||
| 1156 | } | ||
| 1157 | ], | ||
| 1158 | "isUnique": true, | ||
| 1159 | "concurrently": false, | ||
| 1160 | "method": "btree", | ||
| 1161 | "with": {} | ||
| 1162 | } | ||
| 1163 | }, | ||
| 1164 | "foreignKeys": {}, | ||
| 1165 | "compositePrimaryKeys": {}, | ||
| 1166 | "uniqueConstraints": {}, | ||
| 1167 | "policies": {}, | ||
| 1168 | "checkConstraints": {}, | ||
| 1169 | "isRLSEnabled": false | ||
| 1170 | }, | ||
| 1171 | "public.tool_policy": { | ||
| 1172 | "name": "tool_policy", | ||
| 1173 | "schema": "", | ||
| 1174 | "columns": { | ||
| 1175 | "id": { | ||
| 1176 | "name": "id", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "pattern": { | ||
| 1182 | "name": "pattern", | ||
| 1183 | "type": "text", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "action": { | ||
| 1188 | "name": "action", | ||
| 1189 | "type": "text", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | }, | ||
| 1193 | "position": { | ||
| 1194 | "name": "position", | ||
| 1195 | "type": "text", | ||
| 1196 | "primaryKey": false, | ||
| 1197 | "notNull": true | ||
| 1198 | }, | ||
| 1199 | "created_at": { | ||
| 1200 | "name": "created_at", | ||
| 1201 | "type": "timestamp", | ||
| 1202 | "primaryKey": false, | ||
| 1203 | "notNull": true | ||
| 1204 | }, | ||
| 1205 | "updated_at": { | ||
| 1206 | "name": "updated_at", | ||
| 1207 | "type": "timestamp", | ||
| 1208 | "primaryKey": false, | ||
| 1209 | "notNull": true | ||
| 1210 | }, | ||
| 1211 | "row_id": { | ||
| 1212 | "name": "row_id", | ||
| 1213 | "type": "varchar(255)", | ||
| 1214 | "primaryKey": true, | ||
| 1215 | "notNull": true | ||
| 1216 | }, | ||
| 1217 | "tenant": { | ||
| 1218 | "name": "tenant", | ||
| 1219 | "type": "varchar(255)", | ||
| 1220 | "primaryKey": false, | ||
| 1221 | "notNull": true | ||
| 1222 | }, | ||
| 1223 | "owner": { | ||
| 1224 | "name": "owner", | ||
| 1225 | "type": "varchar(255)", | ||
| 1226 | "primaryKey": false, | ||
| 1227 | "notNull": true | ||
| 1228 | }, | ||
| 1229 | "subject": { | ||
| 1230 | "name": "subject", | ||
| 1231 | "type": "varchar(255)", | ||
| 1232 | "primaryKey": false, | ||
| 1233 | "notNull": true | ||
| 1234 | } | ||
| 1235 | }, | ||
| 1236 | "indexes": { | ||
| 1237 | "tool_policy_uidx": { | ||
| 1238 | "name": "tool_policy_uidx", | ||
| 1239 | "columns": [ | ||
| 1240 | { | ||
| 1241 | "expression": "tenant", | ||
| 1242 | "isExpression": false, | ||
| 1243 | "asc": true, | ||
| 1244 | "nulls": "last" | ||
| 1245 | }, | ||
| 1246 | { | ||
| 1247 | "expression": "owner", | ||
| 1248 | "isExpression": false, | ||
| 1249 | "asc": true, | ||
| 1250 | "nulls": "last" | ||
| 1251 | }, | ||
| 1252 | { | ||
| 1253 | "expression": "subject", | ||
| 1254 | "isExpression": false, | ||
| 1255 | "asc": true, | ||
| 1256 | "nulls": "last" | ||
| 1257 | }, | ||
| 1258 | { | ||
| 1259 | "expression": "id", | ||
| 1260 | "isExpression": false, | ||
| 1261 | "asc": true, | ||
| 1262 | "nulls": "last" | ||
| 1263 | } | ||
| 1264 | ], | ||
| 1265 | "isUnique": true, | ||
| 1266 | "concurrently": false, | ||
| 1267 | "method": "btree", | ||
| 1268 | "with": {} | ||
| 1269 | } | ||
| 1270 | }, | ||
| 1271 | "foreignKeys": {}, | ||
| 1272 | "compositePrimaryKeys": {}, | ||
| 1273 | "uniqueConstraints": {}, | ||
| 1274 | "policies": {}, | ||
| 1275 | "checkConstraints": {}, | ||
| 1276 | "isRLSEnabled": false | ||
| 1277 | } | ||
| 1278 | }, | ||
| 1279 | "enums": {}, | ||
| 1280 | "schemas": {}, | ||
| 1281 | "sequences": {}, | ||
| 1282 | "roles": {}, | ||
| 1283 | "policies": {}, | ||
| 1284 | "views": {}, | ||
| 1285 | "_meta": { | ||
| 1286 | "columns": {}, | ||
| 1287 | "schemas": {}, | ||
| 1288 | "tables": {} | ||
| 1289 | } | ||
| 1290 | } | ||
| @@ -0,0 +1,1296 @@ | |||
| 1 | { | ||
| 2 | "id": "e285be7d-2427-4b1b-8b86-50a80ada25af", | ||
| 3 | "prevId": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config": { | ||
| 535 | "name": "config", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "health_check": { | ||
| 541 | "name": "health_check", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "config_revised_at": { | ||
| 547 | "name": "config_revised_at", | ||
| 548 | "type": "bigint", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "origin_redirect_uri": { | ||
| 686 | "name": "origin_redirect_uri", | ||
| 687 | "type": "text", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": false | ||
| 690 | }, | ||
| 691 | "created_at": { | ||
| 692 | "name": "created_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "owner": { | ||
| 710 | "name": "owner", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | }, | ||
| 715 | "subject": { | ||
| 716 | "name": "subject", | ||
| 717 | "type": "varchar(255)", | ||
| 718 | "primaryKey": false, | ||
| 719 | "notNull": true | ||
| 720 | } | ||
| 721 | }, | ||
| 722 | "indexes": { | ||
| 723 | "oauth_client_uidx": { | ||
| 724 | "name": "oauth_client_uidx", | ||
| 725 | "columns": [ | ||
| 726 | { | ||
| 727 | "expression": "tenant", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "owner", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "subject", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "expression": "slug", | ||
| 746 | "isExpression": false, | ||
| 747 | "asc": true, | ||
| 748 | "nulls": "last" | ||
| 749 | } | ||
| 750 | ], | ||
| 751 | "isUnique": true, | ||
| 752 | "concurrently": false, | ||
| 753 | "method": "btree", | ||
| 754 | "with": {} | ||
| 755 | } | ||
| 756 | }, | ||
| 757 | "foreignKeys": {}, | ||
| 758 | "compositePrimaryKeys": {}, | ||
| 759 | "uniqueConstraints": {}, | ||
| 760 | "policies": {}, | ||
| 761 | "checkConstraints": {}, | ||
| 762 | "isRLSEnabled": false | ||
| 763 | }, | ||
| 764 | "public.oauth_session": { | ||
| 765 | "name": "oauth_session", | ||
| 766 | "schema": "", | ||
| 767 | "columns": { | ||
| 768 | "state": { | ||
| 769 | "name": "state", | ||
| 770 | "type": "varchar(255)", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_slug": { | ||
| 775 | "name": "client_slug", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "integration": { | ||
| 781 | "name": "integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "name": { | ||
| 787 | "name": "name", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "template": { | ||
| 793 | "name": "template", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "redirect_url": { | ||
| 799 | "name": "redirect_url", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "pkce_verifier": { | ||
| 805 | "name": "pkce_verifier", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "identity_label": { | ||
| 811 | "name": "identity_label", | ||
| 812 | "type": "text", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": false | ||
| 815 | }, | ||
| 816 | "payload": { | ||
| 817 | "name": "payload", | ||
| 818 | "type": "json", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "expires_at": { | ||
| 823 | "name": "expires_at", | ||
| 824 | "type": "bigint", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "created_at": { | ||
| 829 | "name": "created_at", | ||
| 830 | "type": "timestamp", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "row_id": { | ||
| 835 | "name": "row_id", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": true, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "tenant": { | ||
| 841 | "name": "tenant", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "owner": { | ||
| 847 | "name": "owner", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | }, | ||
| 852 | "subject": { | ||
| 853 | "name": "subject", | ||
| 854 | "type": "varchar(255)", | ||
| 855 | "primaryKey": false, | ||
| 856 | "notNull": true | ||
| 857 | } | ||
| 858 | }, | ||
| 859 | "indexes": { | ||
| 860 | "oauth_session_uidx": { | ||
| 861 | "name": "oauth_session_uidx", | ||
| 862 | "columns": [ | ||
| 863 | { | ||
| 864 | "expression": "tenant", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | }, | ||
| 869 | { | ||
| 870 | "expression": "state", | ||
| 871 | "isExpression": false, | ||
| 872 | "asc": true, | ||
| 873 | "nulls": "last" | ||
| 874 | } | ||
| 875 | ], | ||
| 876 | "isUnique": true, | ||
| 877 | "concurrently": false, | ||
| 878 | "method": "btree", | ||
| 879 | "with": {} | ||
| 880 | } | ||
| 881 | }, | ||
| 882 | "foreignKeys": {}, | ||
| 883 | "compositePrimaryKeys": {}, | ||
| 884 | "uniqueConstraints": {}, | ||
| 885 | "policies": {}, | ||
| 886 | "checkConstraints": {}, | ||
| 887 | "isRLSEnabled": false | ||
| 888 | }, | ||
| 889 | "public.plugin_storage": { | ||
| 890 | "name": "plugin_storage", | ||
| 891 | "schema": "", | ||
| 892 | "columns": { | ||
| 893 | "plugin_id": { | ||
| 894 | "name": "plugin_id", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "collection": { | ||
| 900 | "name": "collection", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "key": { | ||
| 906 | "name": "key", | ||
| 907 | "type": "varchar(255)", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "data": { | ||
| 912 | "name": "data", | ||
| 913 | "type": "json", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "created_at": { | ||
| 918 | "name": "created_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "updated_at": { | ||
| 924 | "name": "updated_at", | ||
| 925 | "type": "timestamp", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "row_id": { | ||
| 930 | "name": "row_id", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": true, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "tenant": { | ||
| 936 | "name": "tenant", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "owner": { | ||
| 942 | "name": "owner", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "subject": { | ||
| 948 | "name": "subject", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | } | ||
| 953 | }, | ||
| 954 | "indexes": { | ||
| 955 | "plugin_storage_uidx": { | ||
| 956 | "name": "plugin_storage_uidx", | ||
| 957 | "columns": [ | ||
| 958 | { | ||
| 959 | "expression": "tenant", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "owner", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "subject", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "plugin_id", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "collection", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "key", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.private_executor_cloud_settings": { | ||
| 1009 | "name": "private_executor_cloud_settings", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "id": { | ||
| 1013 | "name": "id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": true, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "version": { | ||
| 1019 | "name": "version", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true, | ||
| 1023 | "default": "'1.0.0'" | ||
| 1024 | } | ||
| 1025 | }, | ||
| 1026 | "indexes": {}, | ||
| 1027 | "foreignKeys": {}, | ||
| 1028 | "compositePrimaryKeys": {}, | ||
| 1029 | "uniqueConstraints": {}, | ||
| 1030 | "policies": {}, | ||
| 1031 | "checkConstraints": {}, | ||
| 1032 | "isRLSEnabled": false | ||
| 1033 | }, | ||
| 1034 | "public.tool": { | ||
| 1035 | "name": "tool", | ||
| 1036 | "schema": "", | ||
| 1037 | "columns": { | ||
| 1038 | "integration": { | ||
| 1039 | "name": "integration", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "connection": { | ||
| 1045 | "name": "connection", | ||
| 1046 | "type": "varchar(255)", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "plugin_id": { | ||
| 1051 | "name": "plugin_id", | ||
| 1052 | "type": "text", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "name": { | ||
| 1057 | "name": "name", | ||
| 1058 | "type": "varchar(255)", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "description": { | ||
| 1063 | "name": "description", | ||
| 1064 | "type": "text", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": true | ||
| 1067 | }, | ||
| 1068 | "input_schema": { | ||
| 1069 | "name": "input_schema", | ||
| 1070 | "type": "json", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": false | ||
| 1073 | }, | ||
| 1074 | "output_schema": { | ||
| 1075 | "name": "output_schema", | ||
| 1076 | "type": "json", | ||
| 1077 | "primaryKey": false, | ||
| 1078 | "notNull": false | ||
| 1079 | }, | ||
| 1080 | "annotations": { | ||
| 1081 | "name": "annotations", | ||
| 1082 | "type": "json", | ||
| 1083 | "primaryKey": false, | ||
| 1084 | "notNull": false | ||
| 1085 | }, | ||
| 1086 | "created_at": { | ||
| 1087 | "name": "created_at", | ||
| 1088 | "type": "timestamp", | ||
| 1089 | "primaryKey": false, | ||
| 1090 | "notNull": true | ||
| 1091 | }, | ||
| 1092 | "updated_at": { | ||
| 1093 | "name": "updated_at", | ||
| 1094 | "type": "timestamp", | ||
| 1095 | "primaryKey": false, | ||
| 1096 | "notNull": true | ||
| 1097 | }, | ||
| 1098 | "row_id": { | ||
| 1099 | "name": "row_id", | ||
| 1100 | "type": "varchar(255)", | ||
| 1101 | "primaryKey": true, | ||
| 1102 | "notNull": true | ||
| 1103 | }, | ||
| 1104 | "tenant": { | ||
| 1105 | "name": "tenant", | ||
| 1106 | "type": "varchar(255)", | ||
| 1107 | "primaryKey": false, | ||
| 1108 | "notNull": true | ||
| 1109 | }, | ||
| 1110 | "owner": { | ||
| 1111 | "name": "owner", | ||
| 1112 | "type": "varchar(255)", | ||
| 1113 | "primaryKey": false, | ||
| 1114 | "notNull": true | ||
| 1115 | }, | ||
| 1116 | "subject": { | ||
| 1117 | "name": "subject", | ||
| 1118 | "type": "varchar(255)", | ||
| 1119 | "primaryKey": false, | ||
| 1120 | "notNull": true | ||
| 1121 | } | ||
| 1122 | }, | ||
| 1123 | "indexes": { | ||
| 1124 | "tool_uidx": { | ||
| 1125 | "name": "tool_uidx", | ||
| 1126 | "columns": [ | ||
| 1127 | { | ||
| 1128 | "expression": "tenant", | ||
| 1129 | "isExpression": false, | ||
| 1130 | "asc": true, | ||
| 1131 | "nulls": "last" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "expression": "owner", | ||
| 1135 | "isExpression": false, | ||
| 1136 | "asc": true, | ||
| 1137 | "nulls": "last" | ||
| 1138 | }, | ||
| 1139 | { | ||
| 1140 | "expression": "subject", | ||
| 1141 | "isExpression": false, | ||
| 1142 | "asc": true, | ||
| 1143 | "nulls": "last" | ||
| 1144 | }, | ||
| 1145 | { | ||
| 1146 | "expression": "integration", | ||
| 1147 | "isExpression": false, | ||
| 1148 | "asc": true, | ||
| 1149 | "nulls": "last" | ||
| 1150 | }, | ||
| 1151 | { | ||
| 1152 | "expression": "connection", | ||
| 1153 | "isExpression": false, | ||
| 1154 | "asc": true, | ||
| 1155 | "nulls": "last" | ||
| 1156 | }, | ||
| 1157 | { | ||
| 1158 | "expression": "name", | ||
| 1159 | "isExpression": false, | ||
| 1160 | "asc": true, | ||
| 1161 | "nulls": "last" | ||
| 1162 | } | ||
| 1163 | ], | ||
| 1164 | "isUnique": true, | ||
| 1165 | "concurrently": false, | ||
| 1166 | "method": "btree", | ||
| 1167 | "with": {} | ||
| 1168 | } | ||
| 1169 | }, | ||
| 1170 | "foreignKeys": {}, | ||
| 1171 | "compositePrimaryKeys": {}, | ||
| 1172 | "uniqueConstraints": {}, | ||
| 1173 | "policies": {}, | ||
| 1174 | "checkConstraints": {}, | ||
| 1175 | "isRLSEnabled": false | ||
| 1176 | }, | ||
| 1177 | "public.tool_policy": { | ||
| 1178 | "name": "tool_policy", | ||
| 1179 | "schema": "", | ||
| 1180 | "columns": { | ||
| 1181 | "id": { | ||
| 1182 | "name": "id", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "pattern": { | ||
| 1188 | "name": "pattern", | ||
| 1189 | "type": "text", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | }, | ||
| 1193 | "action": { | ||
| 1194 | "name": "action", | ||
| 1195 | "type": "text", | ||
| 1196 | "primaryKey": false, | ||
| 1197 | "notNull": true | ||
| 1198 | }, | ||
| 1199 | "position": { | ||
| 1200 | "name": "position", | ||
| 1201 | "type": "text", | ||
| 1202 | "primaryKey": false, | ||
| 1203 | "notNull": true | ||
| 1204 | }, | ||
| 1205 | "created_at": { | ||
| 1206 | "name": "created_at", | ||
| 1207 | "type": "timestamp", | ||
| 1208 | "primaryKey": false, | ||
| 1209 | "notNull": true | ||
| 1210 | }, | ||
| 1211 | "updated_at": { | ||
| 1212 | "name": "updated_at", | ||
| 1213 | "type": "timestamp", | ||
| 1214 | "primaryKey": false, | ||
| 1215 | "notNull": true | ||
| 1216 | }, | ||
| 1217 | "row_id": { | ||
| 1218 | "name": "row_id", | ||
| 1219 | "type": "varchar(255)", | ||
| 1220 | "primaryKey": true, | ||
| 1221 | "notNull": true | ||
| 1222 | }, | ||
| 1223 | "tenant": { | ||
| 1224 | "name": "tenant", | ||
| 1225 | "type": "varchar(255)", | ||
| 1226 | "primaryKey": false, | ||
| 1227 | "notNull": true | ||
| 1228 | }, | ||
| 1229 | "owner": { | ||
| 1230 | "name": "owner", | ||
| 1231 | "type": "varchar(255)", | ||
| 1232 | "primaryKey": false, | ||
| 1233 | "notNull": true | ||
| 1234 | }, | ||
| 1235 | "subject": { | ||
| 1236 | "name": "subject", | ||
| 1237 | "type": "varchar(255)", | ||
| 1238 | "primaryKey": false, | ||
| 1239 | "notNull": true | ||
| 1240 | } | ||
| 1241 | }, | ||
| 1242 | "indexes": { | ||
| 1243 | "tool_policy_uidx": { | ||
| 1244 | "name": "tool_policy_uidx", | ||
| 1245 | "columns": [ | ||
| 1246 | { | ||
| 1247 | "expression": "tenant", | ||
| 1248 | "isExpression": false, | ||
| 1249 | "asc": true, | ||
| 1250 | "nulls": "last" | ||
| 1251 | }, | ||
| 1252 | { | ||
| 1253 | "expression": "owner", | ||
| 1254 | "isExpression": false, | ||
| 1255 | "asc": true, | ||
| 1256 | "nulls": "last" | ||
| 1257 | }, | ||
| 1258 | { | ||
| 1259 | "expression": "subject", | ||
| 1260 | "isExpression": false, | ||
| 1261 | "asc": true, | ||
| 1262 | "nulls": "last" | ||
| 1263 | }, | ||
| 1264 | { | ||
| 1265 | "expression": "id", | ||
| 1266 | "isExpression": false, | ||
| 1267 | "asc": true, | ||
| 1268 | "nulls": "last" | ||
| 1269 | } | ||
| 1270 | ], | ||
| 1271 | "isUnique": true, | ||
| 1272 | "concurrently": false, | ||
| 1273 | "method": "btree", | ||
| 1274 | "with": {} | ||
| 1275 | } | ||
| 1276 | }, | ||
| 1277 | "foreignKeys": {}, | ||
| 1278 | "compositePrimaryKeys": {}, | ||
| 1279 | "uniqueConstraints": {}, | ||
| 1280 | "policies": {}, | ||
| 1281 | "checkConstraints": {}, | ||
| 1282 | "isRLSEnabled": false | ||
| 1283 | } | ||
| 1284 | }, | ||
| 1285 | "enums": {}, | ||
| 1286 | "schemas": {}, | ||
| 1287 | "sequences": {}, | ||
| 1288 | "roles": {}, | ||
| 1289 | "policies": {}, | ||
| 1290 | "views": {}, | ||
| 1291 | "_meta": { | ||
| 1292 | "columns": {}, | ||
| 1293 | "schemas": {}, | ||
| 1294 | "tables": {} | ||
| 1295 | } | ||
| 1296 | } | ||
| @@ -0,0 +1,1367 @@ | |||
| 1 | { | ||
| 2 | "id": "97114994-f0fc-4419-80bf-21c529a6cf0b", | ||
| 3 | "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config": { | ||
| 535 | "name": "config", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "health_check": { | ||
| 541 | "name": "health_check", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "config_revised_at": { | ||
| 547 | "name": "config_revised_at", | ||
| 548 | "type": "bigint", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "origin_redirect_uri": { | ||
| 686 | "name": "origin_redirect_uri", | ||
| 687 | "type": "text", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": false | ||
| 690 | }, | ||
| 691 | "created_at": { | ||
| 692 | "name": "created_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "owner": { | ||
| 710 | "name": "owner", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | }, | ||
| 715 | "subject": { | ||
| 716 | "name": "subject", | ||
| 717 | "type": "varchar(255)", | ||
| 718 | "primaryKey": false, | ||
| 719 | "notNull": true | ||
| 720 | } | ||
| 721 | }, | ||
| 722 | "indexes": { | ||
| 723 | "oauth_client_uidx": { | ||
| 724 | "name": "oauth_client_uidx", | ||
| 725 | "columns": [ | ||
| 726 | { | ||
| 727 | "expression": "tenant", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "owner", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "subject", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "expression": "slug", | ||
| 746 | "isExpression": false, | ||
| 747 | "asc": true, | ||
| 748 | "nulls": "last" | ||
| 749 | } | ||
| 750 | ], | ||
| 751 | "isUnique": true, | ||
| 752 | "concurrently": false, | ||
| 753 | "method": "btree", | ||
| 754 | "with": {} | ||
| 755 | } | ||
| 756 | }, | ||
| 757 | "foreignKeys": {}, | ||
| 758 | "compositePrimaryKeys": {}, | ||
| 759 | "uniqueConstraints": {}, | ||
| 760 | "policies": {}, | ||
| 761 | "checkConstraints": {}, | ||
| 762 | "isRLSEnabled": false | ||
| 763 | }, | ||
| 764 | "public.oauth_session": { | ||
| 765 | "name": "oauth_session", | ||
| 766 | "schema": "", | ||
| 767 | "columns": { | ||
| 768 | "state": { | ||
| 769 | "name": "state", | ||
| 770 | "type": "varchar(255)", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_slug": { | ||
| 775 | "name": "client_slug", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "integration": { | ||
| 781 | "name": "integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "name": { | ||
| 787 | "name": "name", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "template": { | ||
| 793 | "name": "template", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "redirect_url": { | ||
| 799 | "name": "redirect_url", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "pkce_verifier": { | ||
| 805 | "name": "pkce_verifier", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "identity_label": { | ||
| 811 | "name": "identity_label", | ||
| 812 | "type": "text", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": false | ||
| 815 | }, | ||
| 816 | "payload": { | ||
| 817 | "name": "payload", | ||
| 818 | "type": "json", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "expires_at": { | ||
| 823 | "name": "expires_at", | ||
| 824 | "type": "bigint", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "created_at": { | ||
| 829 | "name": "created_at", | ||
| 830 | "type": "timestamp", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "row_id": { | ||
| 835 | "name": "row_id", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": true, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "tenant": { | ||
| 841 | "name": "tenant", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "owner": { | ||
| 847 | "name": "owner", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | }, | ||
| 852 | "subject": { | ||
| 853 | "name": "subject", | ||
| 854 | "type": "varchar(255)", | ||
| 855 | "primaryKey": false, | ||
| 856 | "notNull": true | ||
| 857 | } | ||
| 858 | }, | ||
| 859 | "indexes": { | ||
| 860 | "oauth_session_uidx": { | ||
| 861 | "name": "oauth_session_uidx", | ||
| 862 | "columns": [ | ||
| 863 | { | ||
| 864 | "expression": "tenant", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | }, | ||
| 869 | { | ||
| 870 | "expression": "state", | ||
| 871 | "isExpression": false, | ||
| 872 | "asc": true, | ||
| 873 | "nulls": "last" | ||
| 874 | } | ||
| 875 | ], | ||
| 876 | "isUnique": true, | ||
| 877 | "concurrently": false, | ||
| 878 | "method": "btree", | ||
| 879 | "with": {} | ||
| 880 | } | ||
| 881 | }, | ||
| 882 | "foreignKeys": {}, | ||
| 883 | "compositePrimaryKeys": {}, | ||
| 884 | "uniqueConstraints": {}, | ||
| 885 | "policies": {}, | ||
| 886 | "checkConstraints": {}, | ||
| 887 | "isRLSEnabled": false | ||
| 888 | }, | ||
| 889 | "public.plugin_storage": { | ||
| 890 | "name": "plugin_storage", | ||
| 891 | "schema": "", | ||
| 892 | "columns": { | ||
| 893 | "plugin_id": { | ||
| 894 | "name": "plugin_id", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "collection": { | ||
| 900 | "name": "collection", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "key": { | ||
| 906 | "name": "key", | ||
| 907 | "type": "varchar(255)", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "data": { | ||
| 912 | "name": "data", | ||
| 913 | "type": "json", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "created_at": { | ||
| 918 | "name": "created_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "updated_at": { | ||
| 924 | "name": "updated_at", | ||
| 925 | "type": "timestamp", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "row_id": { | ||
| 930 | "name": "row_id", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": true, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "tenant": { | ||
| 936 | "name": "tenant", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "owner": { | ||
| 942 | "name": "owner", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "subject": { | ||
| 948 | "name": "subject", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | } | ||
| 953 | }, | ||
| 954 | "indexes": { | ||
| 955 | "plugin_storage_uidx": { | ||
| 956 | "name": "plugin_storage_uidx", | ||
| 957 | "columns": [ | ||
| 958 | { | ||
| 959 | "expression": "tenant", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "owner", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "subject", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "plugin_id", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "collection", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "key", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.private_executor_cloud_settings": { | ||
| 1009 | "name": "private_executor_cloud_settings", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "id": { | ||
| 1013 | "name": "id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": true, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "version": { | ||
| 1019 | "name": "version", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true, | ||
| 1023 | "default": "'1.0.0'" | ||
| 1024 | } | ||
| 1025 | }, | ||
| 1026 | "indexes": {}, | ||
| 1027 | "foreignKeys": {}, | ||
| 1028 | "compositePrimaryKeys": {}, | ||
| 1029 | "uniqueConstraints": {}, | ||
| 1030 | "policies": {}, | ||
| 1031 | "checkConstraints": {}, | ||
| 1032 | "isRLSEnabled": false | ||
| 1033 | }, | ||
| 1034 | "public.subject": { | ||
| 1035 | "name": "subject", | ||
| 1036 | "schema": "", | ||
| 1037 | "columns": { | ||
| 1038 | "external_id": { | ||
| 1039 | "name": "external_id", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "created_at": { | ||
| 1045 | "name": "created_at", | ||
| 1046 | "type": "timestamp", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "last_seen_at": { | ||
| 1051 | "name": "last_seen_at", | ||
| 1052 | "type": "bigint", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": false | ||
| 1055 | }, | ||
| 1056 | "status": { | ||
| 1057 | "name": "status", | ||
| 1058 | "type": "text", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": false | ||
| 1061 | }, | ||
| 1062 | "row_id": { | ||
| 1063 | "name": "row_id", | ||
| 1064 | "type": "varchar(255)", | ||
| 1065 | "primaryKey": true, | ||
| 1066 | "notNull": true | ||
| 1067 | }, | ||
| 1068 | "tenant": { | ||
| 1069 | "name": "tenant", | ||
| 1070 | "type": "varchar(255)", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": true | ||
| 1073 | } | ||
| 1074 | }, | ||
| 1075 | "indexes": { | ||
| 1076 | "subject_uidx": { | ||
| 1077 | "name": "subject_uidx", | ||
| 1078 | "columns": [ | ||
| 1079 | { | ||
| 1080 | "expression": "tenant", | ||
| 1081 | "isExpression": false, | ||
| 1082 | "asc": true, | ||
| 1083 | "nulls": "last" | ||
| 1084 | }, | ||
| 1085 | { | ||
| 1086 | "expression": "external_id", | ||
| 1087 | "isExpression": false, | ||
| 1088 | "asc": true, | ||
| 1089 | "nulls": "last" | ||
| 1090 | } | ||
| 1091 | ], | ||
| 1092 | "isUnique": true, | ||
| 1093 | "concurrently": false, | ||
| 1094 | "method": "btree", | ||
| 1095 | "with": {} | ||
| 1096 | } | ||
| 1097 | }, | ||
| 1098 | "foreignKeys": {}, | ||
| 1099 | "compositePrimaryKeys": {}, | ||
| 1100 | "uniqueConstraints": {}, | ||
| 1101 | "policies": {}, | ||
| 1102 | "checkConstraints": {}, | ||
| 1103 | "isRLSEnabled": false | ||
| 1104 | }, | ||
| 1105 | "public.tool": { | ||
| 1106 | "name": "tool", | ||
| 1107 | "schema": "", | ||
| 1108 | "columns": { | ||
| 1109 | "integration": { | ||
| 1110 | "name": "integration", | ||
| 1111 | "type": "varchar(255)", | ||
| 1112 | "primaryKey": false, | ||
| 1113 | "notNull": true | ||
| 1114 | }, | ||
| 1115 | "connection": { | ||
| 1116 | "name": "connection", | ||
| 1117 | "type": "varchar(255)", | ||
| 1118 | "primaryKey": false, | ||
| 1119 | "notNull": true | ||
| 1120 | }, | ||
| 1121 | "plugin_id": { | ||
| 1122 | "name": "plugin_id", | ||
| 1123 | "type": "text", | ||
| 1124 | "primaryKey": false, | ||
| 1125 | "notNull": true | ||
| 1126 | }, | ||
| 1127 | "name": { | ||
| 1128 | "name": "name", | ||
| 1129 | "type": "varchar(255)", | ||
| 1130 | "primaryKey": false, | ||
| 1131 | "notNull": true | ||
| 1132 | }, | ||
| 1133 | "description": { | ||
| 1134 | "name": "description", | ||
| 1135 | "type": "text", | ||
| 1136 | "primaryKey": false, | ||
| 1137 | "notNull": true | ||
| 1138 | }, | ||
| 1139 | "input_schema": { | ||
| 1140 | "name": "input_schema", | ||
| 1141 | "type": "json", | ||
| 1142 | "primaryKey": false, | ||
| 1143 | "notNull": false | ||
| 1144 | }, | ||
| 1145 | "output_schema": { | ||
| 1146 | "name": "output_schema", | ||
| 1147 | "type": "json", | ||
| 1148 | "primaryKey": false, | ||
| 1149 | "notNull": false | ||
| 1150 | }, | ||
| 1151 | "annotations": { | ||
| 1152 | "name": "annotations", | ||
| 1153 | "type": "json", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": false | ||
| 1156 | }, | ||
| 1157 | "created_at": { | ||
| 1158 | "name": "created_at", | ||
| 1159 | "type": "timestamp", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "updated_at": { | ||
| 1164 | "name": "updated_at", | ||
| 1165 | "type": "timestamp", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": true | ||
| 1168 | }, | ||
| 1169 | "row_id": { | ||
| 1170 | "name": "row_id", | ||
| 1171 | "type": "varchar(255)", | ||
| 1172 | "primaryKey": true, | ||
| 1173 | "notNull": true | ||
| 1174 | }, | ||
| 1175 | "tenant": { | ||
| 1176 | "name": "tenant", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "owner": { | ||
| 1182 | "name": "owner", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "subject": { | ||
| 1188 | "name": "subject", | ||
| 1189 | "type": "varchar(255)", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | } | ||
| 1193 | }, | ||
| 1194 | "indexes": { | ||
| 1195 | "tool_uidx": { | ||
| 1196 | "name": "tool_uidx", | ||
| 1197 | "columns": [ | ||
| 1198 | { | ||
| 1199 | "expression": "tenant", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | }, | ||
| 1204 | { | ||
| 1205 | "expression": "owner", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nulls": "last" | ||
| 1209 | }, | ||
| 1210 | { | ||
| 1211 | "expression": "subject", | ||
| 1212 | "isExpression": false, | ||
| 1213 | "asc": true, | ||
| 1214 | "nulls": "last" | ||
| 1215 | }, | ||
| 1216 | { | ||
| 1217 | "expression": "integration", | ||
| 1218 | "isExpression": false, | ||
| 1219 | "asc": true, | ||
| 1220 | "nulls": "last" | ||
| 1221 | }, | ||
| 1222 | { | ||
| 1223 | "expression": "connection", | ||
| 1224 | "isExpression": false, | ||
| 1225 | "asc": true, | ||
| 1226 | "nulls": "last" | ||
| 1227 | }, | ||
| 1228 | { | ||
| 1229 | "expression": "name", | ||
| 1230 | "isExpression": false, | ||
| 1231 | "asc": true, | ||
| 1232 | "nulls": "last" | ||
| 1233 | } | ||
| 1234 | ], | ||
| 1235 | "isUnique": true, | ||
| 1236 | "concurrently": false, | ||
| 1237 | "method": "btree", | ||
| 1238 | "with": {} | ||
| 1239 | } | ||
| 1240 | }, | ||
| 1241 | "foreignKeys": {}, | ||
| 1242 | "compositePrimaryKeys": {}, | ||
| 1243 | "uniqueConstraints": {}, | ||
| 1244 | "policies": {}, | ||
| 1245 | "checkConstraints": {}, | ||
| 1246 | "isRLSEnabled": false | ||
| 1247 | }, | ||
| 1248 | "public.tool_policy": { | ||
| 1249 | "name": "tool_policy", | ||
| 1250 | "schema": "", | ||
| 1251 | "columns": { | ||
| 1252 | "id": { | ||
| 1253 | "name": "id", | ||
| 1254 | "type": "varchar(255)", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": true | ||
| 1257 | }, | ||
| 1258 | "pattern": { | ||
| 1259 | "name": "pattern", | ||
| 1260 | "type": "text", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": true | ||
| 1263 | }, | ||
| 1264 | "action": { | ||
| 1265 | "name": "action", | ||
| 1266 | "type": "text", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": true | ||
| 1269 | }, | ||
| 1270 | "position": { | ||
| 1271 | "name": "position", | ||
| 1272 | "type": "text", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "created_at": { | ||
| 1277 | "name": "created_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "updated_at": { | ||
| 1283 | "name": "updated_at", | ||
| 1284 | "type": "timestamp", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "row_id": { | ||
| 1289 | "name": "row_id", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": true, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "tenant": { | ||
| 1295 | "name": "tenant", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "owner": { | ||
| 1301 | "name": "owner", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | }, | ||
| 1306 | "subject": { | ||
| 1307 | "name": "subject", | ||
| 1308 | "type": "varchar(255)", | ||
| 1309 | "primaryKey": false, | ||
| 1310 | "notNull": true | ||
| 1311 | } | ||
| 1312 | }, | ||
| 1313 | "indexes": { | ||
| 1314 | "tool_policy_uidx": { | ||
| 1315 | "name": "tool_policy_uidx", | ||
| 1316 | "columns": [ | ||
| 1317 | { | ||
| 1318 | "expression": "tenant", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "owner", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "subject", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "id", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | } | ||
| 1341 | ], | ||
| 1342 | "isUnique": true, | ||
| 1343 | "concurrently": false, | ||
| 1344 | "method": "btree", | ||
| 1345 | "with": {} | ||
| 1346 | } | ||
| 1347 | }, | ||
| 1348 | "foreignKeys": {}, | ||
| 1349 | "compositePrimaryKeys": {}, | ||
| 1350 | "uniqueConstraints": {}, | ||
| 1351 | "policies": {}, | ||
| 1352 | "checkConstraints": {}, | ||
| 1353 | "isRLSEnabled": false | ||
| 1354 | } | ||
| 1355 | }, | ||
| 1356 | "enums": {}, | ||
| 1357 | "schemas": {}, | ||
| 1358 | "sequences": {}, | ||
| 1359 | "roles": {}, | ||
| 1360 | "policies": {}, | ||
| 1361 | "views": {}, | ||
| 1362 | "_meta": { | ||
| 1363 | "columns": {}, | ||
| 1364 | "schemas": {}, | ||
| 1365 | "tables": {} | ||
| 1366 | } | ||
| 1367 | } | ||
| @@ -0,0 +1,1474 @@ | |||
| 1 | { | ||
| 2 | "id": "edc90067-822c-443a-8fcd-3bac77bca317", | ||
| 3 | "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "created_at": { | ||
| 172 | "name": "created_at", | ||
| 173 | "type": "timestamp", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | }, | ||
| 177 | "updated_at": { | ||
| 178 | "name": "updated_at", | ||
| 179 | "type": "timestamp", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": true | ||
| 182 | }, | ||
| 183 | "row_id": { | ||
| 184 | "name": "row_id", | ||
| 185 | "type": "varchar(255)", | ||
| 186 | "primaryKey": true, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "tenant": { | ||
| 190 | "name": "tenant", | ||
| 191 | "type": "varchar(255)", | ||
| 192 | "primaryKey": false, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "owner": { | ||
| 196 | "name": "owner", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": false, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "subject": { | ||
| 202 | "name": "subject", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | } | ||
| 207 | }, | ||
| 208 | "indexes": { | ||
| 209 | "artifact_uidx": { | ||
| 210 | "name": "artifact_uidx", | ||
| 211 | "columns": [ | ||
| 212 | { | ||
| 213 | "expression": "tenant", | ||
| 214 | "isExpression": false, | ||
| 215 | "asc": true, | ||
| 216 | "nulls": "last" | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | "expression": "owner", | ||
| 220 | "isExpression": false, | ||
| 221 | "asc": true, | ||
| 222 | "nulls": "last" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "expression": "subject", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "id", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | } | ||
| 236 | ], | ||
| 237 | "isUnique": true, | ||
| 238 | "concurrently": false, | ||
| 239 | "method": "btree", | ||
| 240 | "with": {} | ||
| 241 | } | ||
| 242 | }, | ||
| 243 | "foreignKeys": {}, | ||
| 244 | "compositePrimaryKeys": {}, | ||
| 245 | "uniqueConstraints": {}, | ||
| 246 | "policies": {}, | ||
| 247 | "checkConstraints": {}, | ||
| 248 | "isRLSEnabled": false | ||
| 249 | }, | ||
| 250 | "public.blob": { | ||
| 251 | "name": "blob", | ||
| 252 | "schema": "", | ||
| 253 | "columns": { | ||
| 254 | "namespace": { | ||
| 255 | "name": "namespace", | ||
| 256 | "type": "varchar(255)", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": true | ||
| 259 | }, | ||
| 260 | "key": { | ||
| 261 | "name": "key", | ||
| 262 | "type": "varchar(255)", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": true | ||
| 265 | }, | ||
| 266 | "value": { | ||
| 267 | "name": "value", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "row_id": { | ||
| 273 | "name": "row_id", | ||
| 274 | "type": "varchar(255)", | ||
| 275 | "primaryKey": true, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "id": { | ||
| 279 | "name": "id", | ||
| 280 | "type": "varchar(255)", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": true | ||
| 283 | } | ||
| 284 | }, | ||
| 285 | "indexes": { | ||
| 286 | "blob_id_uidx": { | ||
| 287 | "name": "blob_id_uidx", | ||
| 288 | "columns": [ | ||
| 289 | { | ||
| 290 | "expression": "id", | ||
| 291 | "isExpression": false, | ||
| 292 | "asc": true, | ||
| 293 | "nulls": "last" | ||
| 294 | } | ||
| 295 | ], | ||
| 296 | "isUnique": true, | ||
| 297 | "concurrently": false, | ||
| 298 | "method": "btree", | ||
| 299 | "with": {} | ||
| 300 | } | ||
| 301 | }, | ||
| 302 | "foreignKeys": {}, | ||
| 303 | "compositePrimaryKeys": {}, | ||
| 304 | "uniqueConstraints": {}, | ||
| 305 | "policies": {}, | ||
| 306 | "checkConstraints": {}, | ||
| 307 | "isRLSEnabled": false | ||
| 308 | }, | ||
| 309 | "public.connection": { | ||
| 310 | "name": "connection", | ||
| 311 | "schema": "", | ||
| 312 | "columns": { | ||
| 313 | "integration": { | ||
| 314 | "name": "integration", | ||
| 315 | "type": "varchar(255)", | ||
| 316 | "primaryKey": false, | ||
| 317 | "notNull": true | ||
| 318 | }, | ||
| 319 | "name": { | ||
| 320 | "name": "name", | ||
| 321 | "type": "varchar(255)", | ||
| 322 | "primaryKey": false, | ||
| 323 | "notNull": true | ||
| 324 | }, | ||
| 325 | "template": { | ||
| 326 | "name": "template", | ||
| 327 | "type": "text", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "provider": { | ||
| 332 | "name": "provider", | ||
| 333 | "type": "text", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "item_ids": { | ||
| 338 | "name": "item_ids", | ||
| 339 | "type": "json", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "identity_label": { | ||
| 344 | "name": "identity_label", | ||
| 345 | "type": "text", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": false | ||
| 348 | }, | ||
| 349 | "description": { | ||
| 350 | "name": "description", | ||
| 351 | "type": "text", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": false | ||
| 354 | }, | ||
| 355 | "last_health": { | ||
| 356 | "name": "last_health", | ||
| 357 | "type": "json", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "tools_synced_at": { | ||
| 362 | "name": "tools_synced_at", | ||
| 363 | "type": "bigint", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "oauth_client": { | ||
| 368 | "name": "oauth_client", | ||
| 369 | "type": "text", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "oauth_client_owner": { | ||
| 374 | "name": "oauth_client_owner", | ||
| 375 | "type": "text", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "refresh_item_id": { | ||
| 380 | "name": "refresh_item_id", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "expires_at": { | ||
| 386 | "name": "expires_at", | ||
| 387 | "type": "bigint", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "oauth_scope": { | ||
| 392 | "name": "oauth_scope", | ||
| 393 | "type": "text", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "oauth_token_url": { | ||
| 398 | "name": "oauth_token_url", | ||
| 399 | "type": "text", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "provider_state": { | ||
| 404 | "name": "provider_state", | ||
| 405 | "type": "json", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "created_at": { | ||
| 410 | "name": "created_at", | ||
| 411 | "type": "timestamp", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "updated_at": { | ||
| 416 | "name": "updated_at", | ||
| 417 | "type": "timestamp", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "row_id": { | ||
| 422 | "name": "row_id", | ||
| 423 | "type": "varchar(255)", | ||
| 424 | "primaryKey": true, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "tenant": { | ||
| 428 | "name": "tenant", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": false, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "owner": { | ||
| 434 | "name": "owner", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "subject": { | ||
| 440 | "name": "subject", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | } | ||
| 445 | }, | ||
| 446 | "indexes": { | ||
| 447 | "connection_uidx": { | ||
| 448 | "name": "connection_uidx", | ||
| 449 | "columns": [ | ||
| 450 | { | ||
| 451 | "expression": "tenant", | ||
| 452 | "isExpression": false, | ||
| 453 | "asc": true, | ||
| 454 | "nulls": "last" | ||
| 455 | }, | ||
| 456 | { | ||
| 457 | "expression": "owner", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "subject", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "integration", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "name", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | } | ||
| 480 | ], | ||
| 481 | "isUnique": true, | ||
| 482 | "concurrently": false, | ||
| 483 | "method": "btree", | ||
| 484 | "with": {} | ||
| 485 | } | ||
| 486 | }, | ||
| 487 | "foreignKeys": {}, | ||
| 488 | "compositePrimaryKeys": {}, | ||
| 489 | "uniqueConstraints": {}, | ||
| 490 | "policies": {}, | ||
| 491 | "checkConstraints": {}, | ||
| 492 | "isRLSEnabled": false | ||
| 493 | }, | ||
| 494 | "public.definition": { | ||
| 495 | "name": "definition", | ||
| 496 | "schema": "", | ||
| 497 | "columns": { | ||
| 498 | "integration": { | ||
| 499 | "name": "integration", | ||
| 500 | "type": "varchar(255)", | ||
| 501 | "primaryKey": false, | ||
| 502 | "notNull": true | ||
| 503 | }, | ||
| 504 | "connection": { | ||
| 505 | "name": "connection", | ||
| 506 | "type": "varchar(255)", | ||
| 507 | "primaryKey": false, | ||
| 508 | "notNull": true | ||
| 509 | }, | ||
| 510 | "plugin_id": { | ||
| 511 | "name": "plugin_id", | ||
| 512 | "type": "text", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "name": { | ||
| 517 | "name": "name", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "schema": { | ||
| 523 | "name": "schema", | ||
| 524 | "type": "json", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "created_at": { | ||
| 529 | "name": "created_at", | ||
| 530 | "type": "timestamp", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "row_id": { | ||
| 535 | "name": "row_id", | ||
| 536 | "type": "varchar(255)", | ||
| 537 | "primaryKey": true, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "tenant": { | ||
| 541 | "name": "tenant", | ||
| 542 | "type": "varchar(255)", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "owner": { | ||
| 547 | "name": "owner", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "subject": { | ||
| 553 | "name": "subject", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | } | ||
| 558 | }, | ||
| 559 | "indexes": { | ||
| 560 | "definition_uidx": { | ||
| 561 | "name": "definition_uidx", | ||
| 562 | "columns": [ | ||
| 563 | { | ||
| 564 | "expression": "tenant", | ||
| 565 | "isExpression": false, | ||
| 566 | "asc": true, | ||
| 567 | "nulls": "last" | ||
| 568 | }, | ||
| 569 | { | ||
| 570 | "expression": "owner", | ||
| 571 | "isExpression": false, | ||
| 572 | "asc": true, | ||
| 573 | "nulls": "last" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "expression": "subject", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "integration", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "connection", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "name", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | } | ||
| 599 | ], | ||
| 600 | "isUnique": true, | ||
| 601 | "concurrently": false, | ||
| 602 | "method": "btree", | ||
| 603 | "with": {} | ||
| 604 | } | ||
| 605 | }, | ||
| 606 | "foreignKeys": {}, | ||
| 607 | "compositePrimaryKeys": {}, | ||
| 608 | "uniqueConstraints": {}, | ||
| 609 | "policies": {}, | ||
| 610 | "checkConstraints": {}, | ||
| 611 | "isRLSEnabled": false | ||
| 612 | }, | ||
| 613 | "public.integration": { | ||
| 614 | "name": "integration", | ||
| 615 | "schema": "", | ||
| 616 | "columns": { | ||
| 617 | "slug": { | ||
| 618 | "name": "slug", | ||
| 619 | "type": "varchar(255)", | ||
| 620 | "primaryKey": false, | ||
| 621 | "notNull": true | ||
| 622 | }, | ||
| 623 | "plugin_id": { | ||
| 624 | "name": "plugin_id", | ||
| 625 | "type": "text", | ||
| 626 | "primaryKey": false, | ||
| 627 | "notNull": true | ||
| 628 | }, | ||
| 629 | "name": { | ||
| 630 | "name": "name", | ||
| 631 | "type": "text", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": false | ||
| 634 | }, | ||
| 635 | "description": { | ||
| 636 | "name": "description", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": false | ||
| 640 | }, | ||
| 641 | "config": { | ||
| 642 | "name": "config", | ||
| 643 | "type": "json", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "health_check": { | ||
| 648 | "name": "health_check", | ||
| 649 | "type": "json", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "config_revised_at": { | ||
| 654 | "name": "config_revised_at", | ||
| 655 | "type": "bigint", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "can_remove": { | ||
| 660 | "name": "can_remove", | ||
| 661 | "type": "boolean", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": true, | ||
| 664 | "default": true | ||
| 665 | }, | ||
| 666 | "can_refresh": { | ||
| 667 | "name": "can_refresh", | ||
| 668 | "type": "boolean", | ||
| 669 | "primaryKey": false, | ||
| 670 | "notNull": true, | ||
| 671 | "default": false | ||
| 672 | }, | ||
| 673 | "created_at": { | ||
| 674 | "name": "created_at", | ||
| 675 | "type": "timestamp", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": true | ||
| 678 | }, | ||
| 679 | "updated_at": { | ||
| 680 | "name": "updated_at", | ||
| 681 | "type": "timestamp", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": true | ||
| 684 | }, | ||
| 685 | "row_id": { | ||
| 686 | "name": "row_id", | ||
| 687 | "type": "varchar(255)", | ||
| 688 | "primaryKey": true, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "tenant": { | ||
| 692 | "name": "tenant", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | } | ||
| 697 | }, | ||
| 698 | "indexes": { | ||
| 699 | "integration_uidx": { | ||
| 700 | "name": "integration_uidx", | ||
| 701 | "columns": [ | ||
| 702 | { | ||
| 703 | "expression": "tenant", | ||
| 704 | "isExpression": false, | ||
| 705 | "asc": true, | ||
| 706 | "nulls": "last" | ||
| 707 | }, | ||
| 708 | { | ||
| 709 | "expression": "slug", | ||
| 710 | "isExpression": false, | ||
| 711 | "asc": true, | ||
| 712 | "nulls": "last" | ||
| 713 | } | ||
| 714 | ], | ||
| 715 | "isUnique": true, | ||
| 716 | "concurrently": false, | ||
| 717 | "method": "btree", | ||
| 718 | "with": {} | ||
| 719 | } | ||
| 720 | }, | ||
| 721 | "foreignKeys": {}, | ||
| 722 | "compositePrimaryKeys": {}, | ||
| 723 | "uniqueConstraints": {}, | ||
| 724 | "policies": {}, | ||
| 725 | "checkConstraints": {}, | ||
| 726 | "isRLSEnabled": false | ||
| 727 | }, | ||
| 728 | "public.oauth_client": { | ||
| 729 | "name": "oauth_client", | ||
| 730 | "schema": "", | ||
| 731 | "columns": { | ||
| 732 | "slug": { | ||
| 733 | "name": "slug", | ||
| 734 | "type": "varchar(255)", | ||
| 735 | "primaryKey": false, | ||
| 736 | "notNull": true | ||
| 737 | }, | ||
| 738 | "authorization_url": { | ||
| 739 | "name": "authorization_url", | ||
| 740 | "type": "text", | ||
| 741 | "primaryKey": false, | ||
| 742 | "notNull": true | ||
| 743 | }, | ||
| 744 | "token_url": { | ||
| 745 | "name": "token_url", | ||
| 746 | "type": "text", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "grant": { | ||
| 751 | "name": "grant", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "client_id": { | ||
| 757 | "name": "client_id", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "client_secret_item_id": { | ||
| 763 | "name": "client_secret_item_id", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": false | ||
| 767 | }, | ||
| 768 | "resource": { | ||
| 769 | "name": "resource", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": false | ||
| 773 | }, | ||
| 774 | "origin_kind": { | ||
| 775 | "name": "origin_kind", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "origin_integration": { | ||
| 781 | "name": "origin_integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_issuer": { | ||
| 787 | "name": "origin_issuer", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_redirect_uri": { | ||
| 793 | "name": "origin_redirect_uri", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "created_at": { | ||
| 799 | "name": "created_at", | ||
| 800 | "type": "timestamp", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "row_id": { | ||
| 805 | "name": "row_id", | ||
| 806 | "type": "varchar(255)", | ||
| 807 | "primaryKey": true, | ||
| 808 | "notNull": true | ||
| 809 | }, | ||
| 810 | "tenant": { | ||
| 811 | "name": "tenant", | ||
| 812 | "type": "varchar(255)", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "owner": { | ||
| 817 | "name": "owner", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "subject": { | ||
| 823 | "name": "subject", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | } | ||
| 828 | }, | ||
| 829 | "indexes": { | ||
| 830 | "oauth_client_uidx": { | ||
| 831 | "name": "oauth_client_uidx", | ||
| 832 | "columns": [ | ||
| 833 | { | ||
| 834 | "expression": "tenant", | ||
| 835 | "isExpression": false, | ||
| 836 | "asc": true, | ||
| 837 | "nulls": "last" | ||
| 838 | }, | ||
| 839 | { | ||
| 840 | "expression": "owner", | ||
| 841 | "isExpression": false, | ||
| 842 | "asc": true, | ||
| 843 | "nulls": "last" | ||
| 844 | }, | ||
| 845 | { | ||
| 846 | "expression": "subject", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "slug", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | } | ||
| 857 | ], | ||
| 858 | "isUnique": true, | ||
| 859 | "concurrently": false, | ||
| 860 | "method": "btree", | ||
| 861 | "with": {} | ||
| 862 | } | ||
| 863 | }, | ||
| 864 | "foreignKeys": {}, | ||
| 865 | "compositePrimaryKeys": {}, | ||
| 866 | "uniqueConstraints": {}, | ||
| 867 | "policies": {}, | ||
| 868 | "checkConstraints": {}, | ||
| 869 | "isRLSEnabled": false | ||
| 870 | }, | ||
| 871 | "public.oauth_session": { | ||
| 872 | "name": "oauth_session", | ||
| 873 | "schema": "", | ||
| 874 | "columns": { | ||
| 875 | "state": { | ||
| 876 | "name": "state", | ||
| 877 | "type": "varchar(255)", | ||
| 878 | "primaryKey": false, | ||
| 879 | "notNull": true | ||
| 880 | }, | ||
| 881 | "client_slug": { | ||
| 882 | "name": "client_slug", | ||
| 883 | "type": "text", | ||
| 884 | "primaryKey": false, | ||
| 885 | "notNull": true | ||
| 886 | }, | ||
| 887 | "integration": { | ||
| 888 | "name": "integration", | ||
| 889 | "type": "text", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "name": { | ||
| 894 | "name": "name", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "template": { | ||
| 900 | "name": "template", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "redirect_url": { | ||
| 906 | "name": "redirect_url", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "pkce_verifier": { | ||
| 912 | "name": "pkce_verifier", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": false | ||
| 916 | }, | ||
| 917 | "identity_label": { | ||
| 918 | "name": "identity_label", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": false | ||
| 922 | }, | ||
| 923 | "payload": { | ||
| 924 | "name": "payload", | ||
| 925 | "type": "json", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "expires_at": { | ||
| 930 | "name": "expires_at", | ||
| 931 | "type": "bigint", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "created_at": { | ||
| 936 | "name": "created_at", | ||
| 937 | "type": "timestamp", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "row_id": { | ||
| 942 | "name": "row_id", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": true, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "tenant": { | ||
| 948 | "name": "tenant", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "owner": { | ||
| 954 | "name": "owner", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": false, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "subject": { | ||
| 960 | "name": "subject", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | } | ||
| 965 | }, | ||
| 966 | "indexes": { | ||
| 967 | "oauth_session_uidx": { | ||
| 968 | "name": "oauth_session_uidx", | ||
| 969 | "columns": [ | ||
| 970 | { | ||
| 971 | "expression": "tenant", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "state", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | } | ||
| 982 | ], | ||
| 983 | "isUnique": true, | ||
| 984 | "concurrently": false, | ||
| 985 | "method": "btree", | ||
| 986 | "with": {} | ||
| 987 | } | ||
| 988 | }, | ||
| 989 | "foreignKeys": {}, | ||
| 990 | "compositePrimaryKeys": {}, | ||
| 991 | "uniqueConstraints": {}, | ||
| 992 | "policies": {}, | ||
| 993 | "checkConstraints": {}, | ||
| 994 | "isRLSEnabled": false | ||
| 995 | }, | ||
| 996 | "public.plugin_storage": { | ||
| 997 | "name": "plugin_storage", | ||
| 998 | "schema": "", | ||
| 999 | "columns": { | ||
| 1000 | "plugin_id": { | ||
| 1001 | "name": "plugin_id", | ||
| 1002 | "type": "varchar(255)", | ||
| 1003 | "primaryKey": false, | ||
| 1004 | "notNull": true | ||
| 1005 | }, | ||
| 1006 | "collection": { | ||
| 1007 | "name": "collection", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": false, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "key": { | ||
| 1013 | "name": "key", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "data": { | ||
| 1019 | "name": "data", | ||
| 1020 | "type": "json", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "created_at": { | ||
| 1025 | "name": "created_at", | ||
| 1026 | "type": "timestamp", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "updated_at": { | ||
| 1031 | "name": "updated_at", | ||
| 1032 | "type": "timestamp", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "row_id": { | ||
| 1037 | "name": "row_id", | ||
| 1038 | "type": "varchar(255)", | ||
| 1039 | "primaryKey": true, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "tenant": { | ||
| 1043 | "name": "tenant", | ||
| 1044 | "type": "varchar(255)", | ||
| 1045 | "primaryKey": false, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "owner": { | ||
| 1049 | "name": "owner", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": false, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "subject": { | ||
| 1055 | "name": "subject", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | } | ||
| 1060 | }, | ||
| 1061 | "indexes": { | ||
| 1062 | "plugin_storage_uidx": { | ||
| 1063 | "name": "plugin_storage_uidx", | ||
| 1064 | "columns": [ | ||
| 1065 | { | ||
| 1066 | "expression": "tenant", | ||
| 1067 | "isExpression": false, | ||
| 1068 | "asc": true, | ||
| 1069 | "nulls": "last" | ||
| 1070 | }, | ||
| 1071 | { | ||
| 1072 | "expression": "owner", | ||
| 1073 | "isExpression": false, | ||
| 1074 | "asc": true, | ||
| 1075 | "nulls": "last" | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | "expression": "subject", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "plugin_id", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "collection", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "key", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | } | ||
| 1101 | ], | ||
| 1102 | "isUnique": true, | ||
| 1103 | "concurrently": false, | ||
| 1104 | "method": "btree", | ||
| 1105 | "with": {} | ||
| 1106 | } | ||
| 1107 | }, | ||
| 1108 | "foreignKeys": {}, | ||
| 1109 | "compositePrimaryKeys": {}, | ||
| 1110 | "uniqueConstraints": {}, | ||
| 1111 | "policies": {}, | ||
| 1112 | "checkConstraints": {}, | ||
| 1113 | "isRLSEnabled": false | ||
| 1114 | }, | ||
| 1115 | "public.private_executor_cloud_settings": { | ||
| 1116 | "name": "private_executor_cloud_settings", | ||
| 1117 | "schema": "", | ||
| 1118 | "columns": { | ||
| 1119 | "id": { | ||
| 1120 | "name": "id", | ||
| 1121 | "type": "varchar(255)", | ||
| 1122 | "primaryKey": true, | ||
| 1123 | "notNull": true | ||
| 1124 | }, | ||
| 1125 | "version": { | ||
| 1126 | "name": "version", | ||
| 1127 | "type": "varchar(255)", | ||
| 1128 | "primaryKey": false, | ||
| 1129 | "notNull": true, | ||
| 1130 | "default": "'1.0.0'" | ||
| 1131 | } | ||
| 1132 | }, | ||
| 1133 | "indexes": {}, | ||
| 1134 | "foreignKeys": {}, | ||
| 1135 | "compositePrimaryKeys": {}, | ||
| 1136 | "uniqueConstraints": {}, | ||
| 1137 | "policies": {}, | ||
| 1138 | "checkConstraints": {}, | ||
| 1139 | "isRLSEnabled": false | ||
| 1140 | }, | ||
| 1141 | "public.subject": { | ||
| 1142 | "name": "subject", | ||
| 1143 | "schema": "", | ||
| 1144 | "columns": { | ||
| 1145 | "external_id": { | ||
| 1146 | "name": "external_id", | ||
| 1147 | "type": "varchar(255)", | ||
| 1148 | "primaryKey": false, | ||
| 1149 | "notNull": true | ||
| 1150 | }, | ||
| 1151 | "created_at": { | ||
| 1152 | "name": "created_at", | ||
| 1153 | "type": "timestamp", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": true | ||
| 1156 | }, | ||
| 1157 | "last_seen_at": { | ||
| 1158 | "name": "last_seen_at", | ||
| 1159 | "type": "bigint", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": false | ||
| 1162 | }, | ||
| 1163 | "status": { | ||
| 1164 | "name": "status", | ||
| 1165 | "type": "text", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": false | ||
| 1168 | }, | ||
| 1169 | "row_id": { | ||
| 1170 | "name": "row_id", | ||
| 1171 | "type": "varchar(255)", | ||
| 1172 | "primaryKey": true, | ||
| 1173 | "notNull": true | ||
| 1174 | }, | ||
| 1175 | "tenant": { | ||
| 1176 | "name": "tenant", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | } | ||
| 1181 | }, | ||
| 1182 | "indexes": { | ||
| 1183 | "subject_uidx": { | ||
| 1184 | "name": "subject_uidx", | ||
| 1185 | "columns": [ | ||
| 1186 | { | ||
| 1187 | "expression": "tenant", | ||
| 1188 | "isExpression": false, | ||
| 1189 | "asc": true, | ||
| 1190 | "nulls": "last" | ||
| 1191 | }, | ||
| 1192 | { | ||
| 1193 | "expression": "external_id", | ||
| 1194 | "isExpression": false, | ||
| 1195 | "asc": true, | ||
| 1196 | "nulls": "last" | ||
| 1197 | } | ||
| 1198 | ], | ||
| 1199 | "isUnique": true, | ||
| 1200 | "concurrently": false, | ||
| 1201 | "method": "btree", | ||
| 1202 | "with": {} | ||
| 1203 | } | ||
| 1204 | }, | ||
| 1205 | "foreignKeys": {}, | ||
| 1206 | "compositePrimaryKeys": {}, | ||
| 1207 | "uniqueConstraints": {}, | ||
| 1208 | "policies": {}, | ||
| 1209 | "checkConstraints": {}, | ||
| 1210 | "isRLSEnabled": false | ||
| 1211 | }, | ||
| 1212 | "public.tool": { | ||
| 1213 | "name": "tool", | ||
| 1214 | "schema": "", | ||
| 1215 | "columns": { | ||
| 1216 | "integration": { | ||
| 1217 | "name": "integration", | ||
| 1218 | "type": "varchar(255)", | ||
| 1219 | "primaryKey": false, | ||
| 1220 | "notNull": true | ||
| 1221 | }, | ||
| 1222 | "connection": { | ||
| 1223 | "name": "connection", | ||
| 1224 | "type": "varchar(255)", | ||
| 1225 | "primaryKey": false, | ||
| 1226 | "notNull": true | ||
| 1227 | }, | ||
| 1228 | "plugin_id": { | ||
| 1229 | "name": "plugin_id", | ||
| 1230 | "type": "text", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "name": { | ||
| 1235 | "name": "name", | ||
| 1236 | "type": "varchar(255)", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "description": { | ||
| 1241 | "name": "description", | ||
| 1242 | "type": "text", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "input_schema": { | ||
| 1247 | "name": "input_schema", | ||
| 1248 | "type": "json", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": false | ||
| 1251 | }, | ||
| 1252 | "output_schema": { | ||
| 1253 | "name": "output_schema", | ||
| 1254 | "type": "json", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": false | ||
| 1257 | }, | ||
| 1258 | "annotations": { | ||
| 1259 | "name": "annotations", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "created_at": { | ||
| 1265 | "name": "created_at", | ||
| 1266 | "type": "timestamp", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": true | ||
| 1269 | }, | ||
| 1270 | "updated_at": { | ||
| 1271 | "name": "updated_at", | ||
| 1272 | "type": "timestamp", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "row_id": { | ||
| 1277 | "name": "row_id", | ||
| 1278 | "type": "varchar(255)", | ||
| 1279 | "primaryKey": true, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "tenant": { | ||
| 1283 | "name": "tenant", | ||
| 1284 | "type": "varchar(255)", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "owner": { | ||
| 1289 | "name": "owner", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": false, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "subject": { | ||
| 1295 | "name": "subject", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | } | ||
| 1300 | }, | ||
| 1301 | "indexes": { | ||
| 1302 | "tool_uidx": { | ||
| 1303 | "name": "tool_uidx", | ||
| 1304 | "columns": [ | ||
| 1305 | { | ||
| 1306 | "expression": "tenant", | ||
| 1307 | "isExpression": false, | ||
| 1308 | "asc": true, | ||
| 1309 | "nulls": "last" | ||
| 1310 | }, | ||
| 1311 | { | ||
| 1312 | "expression": "owner", | ||
| 1313 | "isExpression": false, | ||
| 1314 | "asc": true, | ||
| 1315 | "nulls": "last" | ||
| 1316 | }, | ||
| 1317 | { | ||
| 1318 | "expression": "subject", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "integration", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "connection", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "name", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | } | ||
| 1341 | ], | ||
| 1342 | "isUnique": true, | ||
| 1343 | "concurrently": false, | ||
| 1344 | "method": "btree", | ||
| 1345 | "with": {} | ||
| 1346 | } | ||
| 1347 | }, | ||
| 1348 | "foreignKeys": {}, | ||
| 1349 | "compositePrimaryKeys": {}, | ||
| 1350 | "uniqueConstraints": {}, | ||
| 1351 | "policies": {}, | ||
| 1352 | "checkConstraints": {}, | ||
| 1353 | "isRLSEnabled": false | ||
| 1354 | }, | ||
| 1355 | "public.tool_policy": { | ||
| 1356 | "name": "tool_policy", | ||
| 1357 | "schema": "", | ||
| 1358 | "columns": { | ||
| 1359 | "id": { | ||
| 1360 | "name": "id", | ||
| 1361 | "type": "varchar(255)", | ||
| 1362 | "primaryKey": false, | ||
| 1363 | "notNull": true | ||
| 1364 | }, | ||
| 1365 | "pattern": { | ||
| 1366 | "name": "pattern", | ||
| 1367 | "type": "text", | ||
| 1368 | "primaryKey": false, | ||
| 1369 | "notNull": true | ||
| 1370 | }, | ||
| 1371 | "action": { | ||
| 1372 | "name": "action", | ||
| 1373 | "type": "text", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "position": { | ||
| 1378 | "name": "position", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "created_at": { | ||
| 1384 | "name": "created_at", | ||
| 1385 | "type": "timestamp", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "updated_at": { | ||
| 1390 | "name": "updated_at", | ||
| 1391 | "type": "timestamp", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "row_id": { | ||
| 1396 | "name": "row_id", | ||
| 1397 | "type": "varchar(255)", | ||
| 1398 | "primaryKey": true, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "tenant": { | ||
| 1402 | "name": "tenant", | ||
| 1403 | "type": "varchar(255)", | ||
| 1404 | "primaryKey": false, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "owner": { | ||
| 1408 | "name": "owner", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": false, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "subject": { | ||
| 1414 | "name": "subject", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | } | ||
| 1419 | }, | ||
| 1420 | "indexes": { | ||
| 1421 | "tool_policy_uidx": { | ||
| 1422 | "name": "tool_policy_uidx", | ||
| 1423 | "columns": [ | ||
| 1424 | { | ||
| 1425 | "expression": "tenant", | ||
| 1426 | "isExpression": false, | ||
| 1427 | "asc": true, | ||
| 1428 | "nulls": "last" | ||
| 1429 | }, | ||
| 1430 | { | ||
| 1431 | "expression": "owner", | ||
| 1432 | "isExpression": false, | ||
| 1433 | "asc": true, | ||
| 1434 | "nulls": "last" | ||
| 1435 | }, | ||
| 1436 | { | ||
| 1437 | "expression": "subject", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "id", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | } | ||
| 1448 | ], | ||
| 1449 | "isUnique": true, | ||
| 1450 | "concurrently": false, | ||
| 1451 | "method": "btree", | ||
| 1452 | "with": {} | ||
| 1453 | } | ||
| 1454 | }, | ||
| 1455 | "foreignKeys": {}, | ||
| 1456 | "compositePrimaryKeys": {}, | ||
| 1457 | "uniqueConstraints": {}, | ||
| 1458 | "policies": {}, | ||
| 1459 | "checkConstraints": {}, | ||
| 1460 | "isRLSEnabled": false | ||
| 1461 | } | ||
| 1462 | }, | ||
| 1463 | "enums": {}, | ||
| 1464 | "schemas": {}, | ||
| 1465 | "sequences": {}, | ||
| 1466 | "roles": {}, | ||
| 1467 | "policies": {}, | ||
| 1468 | "views": {}, | ||
| 1469 | "_meta": { | ||
| 1470 | "columns": {}, | ||
| 1471 | "schemas": {}, | ||
| 1472 | "tables": {} | ||
| 1473 | } | ||
| 1474 | } | ||
| @@ -0,0 +1,1480 @@ | |||
| 1 | { | ||
| 2 | "id": "0857c83f-0124-4739-bd88-a0a4a92aab84", | ||
| 3 | "prevId": "edc90067-822c-443a-8fcd-3bac77bca317", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "bindings": { | ||
| 172 | "name": "bindings", | ||
| 173 | "type": "json", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": false | ||
| 176 | }, | ||
| 177 | "created_at": { | ||
| 178 | "name": "created_at", | ||
| 179 | "type": "timestamp", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": true | ||
| 182 | }, | ||
| 183 | "updated_at": { | ||
| 184 | "name": "updated_at", | ||
| 185 | "type": "timestamp", | ||
| 186 | "primaryKey": false, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "row_id": { | ||
| 190 | "name": "row_id", | ||
| 191 | "type": "varchar(255)", | ||
| 192 | "primaryKey": true, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "tenant": { | ||
| 196 | "name": "tenant", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": false, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "owner": { | ||
| 202 | "name": "owner", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | }, | ||
| 207 | "subject": { | ||
| 208 | "name": "subject", | ||
| 209 | "type": "varchar(255)", | ||
| 210 | "primaryKey": false, | ||
| 211 | "notNull": true | ||
| 212 | } | ||
| 213 | }, | ||
| 214 | "indexes": { | ||
| 215 | "artifact_uidx": { | ||
| 216 | "name": "artifact_uidx", | ||
| 217 | "columns": [ | ||
| 218 | { | ||
| 219 | "expression": "tenant", | ||
| 220 | "isExpression": false, | ||
| 221 | "asc": true, | ||
| 222 | "nulls": "last" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "expression": "owner", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "subject", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | }, | ||
| 236 | { | ||
| 237 | "expression": "id", | ||
| 238 | "isExpression": false, | ||
| 239 | "asc": true, | ||
| 240 | "nulls": "last" | ||
| 241 | } | ||
| 242 | ], | ||
| 243 | "isUnique": true, | ||
| 244 | "concurrently": false, | ||
| 245 | "method": "btree", | ||
| 246 | "with": {} | ||
| 247 | } | ||
| 248 | }, | ||
| 249 | "foreignKeys": {}, | ||
| 250 | "compositePrimaryKeys": {}, | ||
| 251 | "uniqueConstraints": {}, | ||
| 252 | "policies": {}, | ||
| 253 | "checkConstraints": {}, | ||
| 254 | "isRLSEnabled": false | ||
| 255 | }, | ||
| 256 | "public.blob": { | ||
| 257 | "name": "blob", | ||
| 258 | "schema": "", | ||
| 259 | "columns": { | ||
| 260 | "namespace": { | ||
| 261 | "name": "namespace", | ||
| 262 | "type": "varchar(255)", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": true | ||
| 265 | }, | ||
| 266 | "key": { | ||
| 267 | "name": "key", | ||
| 268 | "type": "varchar(255)", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "value": { | ||
| 273 | "name": "value", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "row_id": { | ||
| 279 | "name": "row_id", | ||
| 280 | "type": "varchar(255)", | ||
| 281 | "primaryKey": true, | ||
| 282 | "notNull": true | ||
| 283 | }, | ||
| 284 | "id": { | ||
| 285 | "name": "id", | ||
| 286 | "type": "varchar(255)", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": true | ||
| 289 | } | ||
| 290 | }, | ||
| 291 | "indexes": { | ||
| 292 | "blob_id_uidx": { | ||
| 293 | "name": "blob_id_uidx", | ||
| 294 | "columns": [ | ||
| 295 | { | ||
| 296 | "expression": "id", | ||
| 297 | "isExpression": false, | ||
| 298 | "asc": true, | ||
| 299 | "nulls": "last" | ||
| 300 | } | ||
| 301 | ], | ||
| 302 | "isUnique": true, | ||
| 303 | "concurrently": false, | ||
| 304 | "method": "btree", | ||
| 305 | "with": {} | ||
| 306 | } | ||
| 307 | }, | ||
| 308 | "foreignKeys": {}, | ||
| 309 | "compositePrimaryKeys": {}, | ||
| 310 | "uniqueConstraints": {}, | ||
| 311 | "policies": {}, | ||
| 312 | "checkConstraints": {}, | ||
| 313 | "isRLSEnabled": false | ||
| 314 | }, | ||
| 315 | "public.connection": { | ||
| 316 | "name": "connection", | ||
| 317 | "schema": "", | ||
| 318 | "columns": { | ||
| 319 | "integration": { | ||
| 320 | "name": "integration", | ||
| 321 | "type": "varchar(255)", | ||
| 322 | "primaryKey": false, | ||
| 323 | "notNull": true | ||
| 324 | }, | ||
| 325 | "name": { | ||
| 326 | "name": "name", | ||
| 327 | "type": "varchar(255)", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "template": { | ||
| 332 | "name": "template", | ||
| 333 | "type": "text", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "provider": { | ||
| 338 | "name": "provider", | ||
| 339 | "type": "text", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "item_ids": { | ||
| 344 | "name": "item_ids", | ||
| 345 | "type": "json", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": true | ||
| 348 | }, | ||
| 349 | "identity_label": { | ||
| 350 | "name": "identity_label", | ||
| 351 | "type": "text", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": false | ||
| 354 | }, | ||
| 355 | "description": { | ||
| 356 | "name": "description", | ||
| 357 | "type": "text", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "last_health": { | ||
| 362 | "name": "last_health", | ||
| 363 | "type": "json", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "tools_synced_at": { | ||
| 368 | "name": "tools_synced_at", | ||
| 369 | "type": "bigint", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "oauth_client": { | ||
| 374 | "name": "oauth_client", | ||
| 375 | "type": "text", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "oauth_client_owner": { | ||
| 380 | "name": "oauth_client_owner", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "refresh_item_id": { | ||
| 386 | "name": "refresh_item_id", | ||
| 387 | "type": "text", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "expires_at": { | ||
| 392 | "name": "expires_at", | ||
| 393 | "type": "bigint", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "oauth_scope": { | ||
| 398 | "name": "oauth_scope", | ||
| 399 | "type": "text", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "oauth_token_url": { | ||
| 404 | "name": "oauth_token_url", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "provider_state": { | ||
| 410 | "name": "provider_state", | ||
| 411 | "type": "json", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": false | ||
| 414 | }, | ||
| 415 | "created_at": { | ||
| 416 | "name": "created_at", | ||
| 417 | "type": "timestamp", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "updated_at": { | ||
| 422 | "name": "updated_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "connection_uidx": { | ||
| 454 | "name": "connection_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "name", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | } | ||
| 486 | ], | ||
| 487 | "isUnique": true, | ||
| 488 | "concurrently": false, | ||
| 489 | "method": "btree", | ||
| 490 | "with": {} | ||
| 491 | } | ||
| 492 | }, | ||
| 493 | "foreignKeys": {}, | ||
| 494 | "compositePrimaryKeys": {}, | ||
| 495 | "uniqueConstraints": {}, | ||
| 496 | "policies": {}, | ||
| 497 | "checkConstraints": {}, | ||
| 498 | "isRLSEnabled": false | ||
| 499 | }, | ||
| 500 | "public.definition": { | ||
| 501 | "name": "definition", | ||
| 502 | "schema": "", | ||
| 503 | "columns": { | ||
| 504 | "integration": { | ||
| 505 | "name": "integration", | ||
| 506 | "type": "varchar(255)", | ||
| 507 | "primaryKey": false, | ||
| 508 | "notNull": true | ||
| 509 | }, | ||
| 510 | "connection": { | ||
| 511 | "name": "connection", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "schema": { | ||
| 529 | "name": "schema", | ||
| 530 | "type": "json", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "created_at": { | ||
| 535 | "name": "created_at", | ||
| 536 | "type": "timestamp", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "row_id": { | ||
| 541 | "name": "row_id", | ||
| 542 | "type": "varchar(255)", | ||
| 543 | "primaryKey": true, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "tenant": { | ||
| 547 | "name": "tenant", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "owner": { | ||
| 553 | "name": "owner", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | }, | ||
| 558 | "subject": { | ||
| 559 | "name": "subject", | ||
| 560 | "type": "varchar(255)", | ||
| 561 | "primaryKey": false, | ||
| 562 | "notNull": true | ||
| 563 | } | ||
| 564 | }, | ||
| 565 | "indexes": { | ||
| 566 | "definition_uidx": { | ||
| 567 | "name": "definition_uidx", | ||
| 568 | "columns": [ | ||
| 569 | { | ||
| 570 | "expression": "tenant", | ||
| 571 | "isExpression": false, | ||
| 572 | "asc": true, | ||
| 573 | "nulls": "last" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "expression": "owner", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "subject", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "integration", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "connection", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | }, | ||
| 599 | { | ||
| 600 | "expression": "name", | ||
| 601 | "isExpression": false, | ||
| 602 | "asc": true, | ||
| 603 | "nulls": "last" | ||
| 604 | } | ||
| 605 | ], | ||
| 606 | "isUnique": true, | ||
| 607 | "concurrently": false, | ||
| 608 | "method": "btree", | ||
| 609 | "with": {} | ||
| 610 | } | ||
| 611 | }, | ||
| 612 | "foreignKeys": {}, | ||
| 613 | "compositePrimaryKeys": {}, | ||
| 614 | "uniqueConstraints": {}, | ||
| 615 | "policies": {}, | ||
| 616 | "checkConstraints": {}, | ||
| 617 | "isRLSEnabled": false | ||
| 618 | }, | ||
| 619 | "public.integration": { | ||
| 620 | "name": "integration", | ||
| 621 | "schema": "", | ||
| 622 | "columns": { | ||
| 623 | "slug": { | ||
| 624 | "name": "slug", | ||
| 625 | "type": "varchar(255)", | ||
| 626 | "primaryKey": false, | ||
| 627 | "notNull": true | ||
| 628 | }, | ||
| 629 | "plugin_id": { | ||
| 630 | "name": "plugin_id", | ||
| 631 | "type": "text", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": true | ||
| 634 | }, | ||
| 635 | "name": { | ||
| 636 | "name": "name", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": false | ||
| 640 | }, | ||
| 641 | "description": { | ||
| 642 | "name": "description", | ||
| 643 | "type": "text", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "config": { | ||
| 648 | "name": "config", | ||
| 649 | "type": "json", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "health_check": { | ||
| 654 | "name": "health_check", | ||
| 655 | "type": "json", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "config_revised_at": { | ||
| 660 | "name": "config_revised_at", | ||
| 661 | "type": "bigint", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": false | ||
| 664 | }, | ||
| 665 | "can_remove": { | ||
| 666 | "name": "can_remove", | ||
| 667 | "type": "boolean", | ||
| 668 | "primaryKey": false, | ||
| 669 | "notNull": true, | ||
| 670 | "default": true | ||
| 671 | }, | ||
| 672 | "can_refresh": { | ||
| 673 | "name": "can_refresh", | ||
| 674 | "type": "boolean", | ||
| 675 | "primaryKey": false, | ||
| 676 | "notNull": true, | ||
| 677 | "default": false | ||
| 678 | }, | ||
| 679 | "created_at": { | ||
| 680 | "name": "created_at", | ||
| 681 | "type": "timestamp", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": true | ||
| 684 | }, | ||
| 685 | "updated_at": { | ||
| 686 | "name": "updated_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "row_id": { | ||
| 692 | "name": "row_id", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": true, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "tenant": { | ||
| 698 | "name": "tenant", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": false, | ||
| 701 | "notNull": true | ||
| 702 | } | ||
| 703 | }, | ||
| 704 | "indexes": { | ||
| 705 | "integration_uidx": { | ||
| 706 | "name": "integration_uidx", | ||
| 707 | "columns": [ | ||
| 708 | { | ||
| 709 | "expression": "tenant", | ||
| 710 | "isExpression": false, | ||
| 711 | "asc": true, | ||
| 712 | "nulls": "last" | ||
| 713 | }, | ||
| 714 | { | ||
| 715 | "expression": "slug", | ||
| 716 | "isExpression": false, | ||
| 717 | "asc": true, | ||
| 718 | "nulls": "last" | ||
| 719 | } | ||
| 720 | ], | ||
| 721 | "isUnique": true, | ||
| 722 | "concurrently": false, | ||
| 723 | "method": "btree", | ||
| 724 | "with": {} | ||
| 725 | } | ||
| 726 | }, | ||
| 727 | "foreignKeys": {}, | ||
| 728 | "compositePrimaryKeys": {}, | ||
| 729 | "uniqueConstraints": {}, | ||
| 730 | "policies": {}, | ||
| 731 | "checkConstraints": {}, | ||
| 732 | "isRLSEnabled": false | ||
| 733 | }, | ||
| 734 | "public.oauth_client": { | ||
| 735 | "name": "oauth_client", | ||
| 736 | "schema": "", | ||
| 737 | "columns": { | ||
| 738 | "slug": { | ||
| 739 | "name": "slug", | ||
| 740 | "type": "varchar(255)", | ||
| 741 | "primaryKey": false, | ||
| 742 | "notNull": true | ||
| 743 | }, | ||
| 744 | "authorization_url": { | ||
| 745 | "name": "authorization_url", | ||
| 746 | "type": "text", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "token_url": { | ||
| 751 | "name": "token_url", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "grant": { | ||
| 757 | "name": "grant", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "client_id": { | ||
| 763 | "name": "client_id", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_secret_item_id": { | ||
| 769 | "name": "client_secret_item_id", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": false | ||
| 773 | }, | ||
| 774 | "resource": { | ||
| 775 | "name": "resource", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "origin_kind": { | ||
| 781 | "name": "origin_kind", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_integration": { | ||
| 787 | "name": "origin_integration", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_issuer": { | ||
| 793 | "name": "origin_issuer", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "origin_redirect_uri": { | ||
| 799 | "name": "origin_redirect_uri", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "created_at": { | ||
| 805 | "name": "created_at", | ||
| 806 | "type": "timestamp", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": true | ||
| 809 | }, | ||
| 810 | "row_id": { | ||
| 811 | "name": "row_id", | ||
| 812 | "type": "varchar(255)", | ||
| 813 | "primaryKey": true, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "tenant": { | ||
| 817 | "name": "tenant", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "owner": { | ||
| 823 | "name": "owner", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "subject": { | ||
| 829 | "name": "subject", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | } | ||
| 834 | }, | ||
| 835 | "indexes": { | ||
| 836 | "oauth_client_uidx": { | ||
| 837 | "name": "oauth_client_uidx", | ||
| 838 | "columns": [ | ||
| 839 | { | ||
| 840 | "expression": "tenant", | ||
| 841 | "isExpression": false, | ||
| 842 | "asc": true, | ||
| 843 | "nulls": "last" | ||
| 844 | }, | ||
| 845 | { | ||
| 846 | "expression": "owner", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "subject", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | }, | ||
| 857 | { | ||
| 858 | "expression": "slug", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | } | ||
| 863 | ], | ||
| 864 | "isUnique": true, | ||
| 865 | "concurrently": false, | ||
| 866 | "method": "btree", | ||
| 867 | "with": {} | ||
| 868 | } | ||
| 869 | }, | ||
| 870 | "foreignKeys": {}, | ||
| 871 | "compositePrimaryKeys": {}, | ||
| 872 | "uniqueConstraints": {}, | ||
| 873 | "policies": {}, | ||
| 874 | "checkConstraints": {}, | ||
| 875 | "isRLSEnabled": false | ||
| 876 | }, | ||
| 877 | "public.oauth_session": { | ||
| 878 | "name": "oauth_session", | ||
| 879 | "schema": "", | ||
| 880 | "columns": { | ||
| 881 | "state": { | ||
| 882 | "name": "state", | ||
| 883 | "type": "varchar(255)", | ||
| 884 | "primaryKey": false, | ||
| 885 | "notNull": true | ||
| 886 | }, | ||
| 887 | "client_slug": { | ||
| 888 | "name": "client_slug", | ||
| 889 | "type": "text", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "integration": { | ||
| 894 | "name": "integration", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "name": { | ||
| 900 | "name": "name", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "template": { | ||
| 906 | "name": "template", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "redirect_url": { | ||
| 912 | "name": "redirect_url", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "pkce_verifier": { | ||
| 918 | "name": "pkce_verifier", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": false | ||
| 922 | }, | ||
| 923 | "identity_label": { | ||
| 924 | "name": "identity_label", | ||
| 925 | "type": "text", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": false | ||
| 928 | }, | ||
| 929 | "payload": { | ||
| 930 | "name": "payload", | ||
| 931 | "type": "json", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "expires_at": { | ||
| 936 | "name": "expires_at", | ||
| 937 | "type": "bigint", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "created_at": { | ||
| 942 | "name": "created_at", | ||
| 943 | "type": "timestamp", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "row_id": { | ||
| 948 | "name": "row_id", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": true, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "tenant": { | ||
| 954 | "name": "tenant", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": false, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "owner": { | ||
| 960 | "name": "owner", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | }, | ||
| 965 | "subject": { | ||
| 966 | "name": "subject", | ||
| 967 | "type": "varchar(255)", | ||
| 968 | "primaryKey": false, | ||
| 969 | "notNull": true | ||
| 970 | } | ||
| 971 | }, | ||
| 972 | "indexes": { | ||
| 973 | "oauth_session_uidx": { | ||
| 974 | "name": "oauth_session_uidx", | ||
| 975 | "columns": [ | ||
| 976 | { | ||
| 977 | "expression": "tenant", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "state", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | } | ||
| 988 | ], | ||
| 989 | "isUnique": true, | ||
| 990 | "concurrently": false, | ||
| 991 | "method": "btree", | ||
| 992 | "with": {} | ||
| 993 | } | ||
| 994 | }, | ||
| 995 | "foreignKeys": {}, | ||
| 996 | "compositePrimaryKeys": {}, | ||
| 997 | "uniqueConstraints": {}, | ||
| 998 | "policies": {}, | ||
| 999 | "checkConstraints": {}, | ||
| 1000 | "isRLSEnabled": false | ||
| 1001 | }, | ||
| 1002 | "public.plugin_storage": { | ||
| 1003 | "name": "plugin_storage", | ||
| 1004 | "schema": "", | ||
| 1005 | "columns": { | ||
| 1006 | "plugin_id": { | ||
| 1007 | "name": "plugin_id", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": false, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "collection": { | ||
| 1013 | "name": "collection", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "key": { | ||
| 1019 | "name": "key", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "data": { | ||
| 1025 | "name": "data", | ||
| 1026 | "type": "json", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "created_at": { | ||
| 1031 | "name": "created_at", | ||
| 1032 | "type": "timestamp", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "updated_at": { | ||
| 1037 | "name": "updated_at", | ||
| 1038 | "type": "timestamp", | ||
| 1039 | "primaryKey": false, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "row_id": { | ||
| 1043 | "name": "row_id", | ||
| 1044 | "type": "varchar(255)", | ||
| 1045 | "primaryKey": true, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "tenant": { | ||
| 1049 | "name": "tenant", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": false, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "owner": { | ||
| 1055 | "name": "owner", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | }, | ||
| 1060 | "subject": { | ||
| 1061 | "name": "subject", | ||
| 1062 | "type": "varchar(255)", | ||
| 1063 | "primaryKey": false, | ||
| 1064 | "notNull": true | ||
| 1065 | } | ||
| 1066 | }, | ||
| 1067 | "indexes": { | ||
| 1068 | "plugin_storage_uidx": { | ||
| 1069 | "name": "plugin_storage_uidx", | ||
| 1070 | "columns": [ | ||
| 1071 | { | ||
| 1072 | "expression": "tenant", | ||
| 1073 | "isExpression": false, | ||
| 1074 | "asc": true, | ||
| 1075 | "nulls": "last" | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | "expression": "owner", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "subject", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "plugin_id", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "collection", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | }, | ||
| 1101 | { | ||
| 1102 | "expression": "key", | ||
| 1103 | "isExpression": false, | ||
| 1104 | "asc": true, | ||
| 1105 | "nulls": "last" | ||
| 1106 | } | ||
| 1107 | ], | ||
| 1108 | "isUnique": true, | ||
| 1109 | "concurrently": false, | ||
| 1110 | "method": "btree", | ||
| 1111 | "with": {} | ||
| 1112 | } | ||
| 1113 | }, | ||
| 1114 | "foreignKeys": {}, | ||
| 1115 | "compositePrimaryKeys": {}, | ||
| 1116 | "uniqueConstraints": {}, | ||
| 1117 | "policies": {}, | ||
| 1118 | "checkConstraints": {}, | ||
| 1119 | "isRLSEnabled": false | ||
| 1120 | }, | ||
| 1121 | "public.private_executor_cloud_settings": { | ||
| 1122 | "name": "private_executor_cloud_settings", | ||
| 1123 | "schema": "", | ||
| 1124 | "columns": { | ||
| 1125 | "id": { | ||
| 1126 | "name": "id", | ||
| 1127 | "type": "varchar(255)", | ||
| 1128 | "primaryKey": true, | ||
| 1129 | "notNull": true | ||
| 1130 | }, | ||
| 1131 | "version": { | ||
| 1132 | "name": "version", | ||
| 1133 | "type": "varchar(255)", | ||
| 1134 | "primaryKey": false, | ||
| 1135 | "notNull": true, | ||
| 1136 | "default": "'1.0.0'" | ||
| 1137 | } | ||
| 1138 | }, | ||
| 1139 | "indexes": {}, | ||
| 1140 | "foreignKeys": {}, | ||
| 1141 | "compositePrimaryKeys": {}, | ||
| 1142 | "uniqueConstraints": {}, | ||
| 1143 | "policies": {}, | ||
| 1144 | "checkConstraints": {}, | ||
| 1145 | "isRLSEnabled": false | ||
| 1146 | }, | ||
| 1147 | "public.subject": { | ||
| 1148 | "name": "subject", | ||
| 1149 | "schema": "", | ||
| 1150 | "columns": { | ||
| 1151 | "external_id": { | ||
| 1152 | "name": "external_id", | ||
| 1153 | "type": "varchar(255)", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": true | ||
| 1156 | }, | ||
| 1157 | "created_at": { | ||
| 1158 | "name": "created_at", | ||
| 1159 | "type": "timestamp", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "last_seen_at": { | ||
| 1164 | "name": "last_seen_at", | ||
| 1165 | "type": "bigint", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": false | ||
| 1168 | }, | ||
| 1169 | "status": { | ||
| 1170 | "name": "status", | ||
| 1171 | "type": "text", | ||
| 1172 | "primaryKey": false, | ||
| 1173 | "notNull": false | ||
| 1174 | }, | ||
| 1175 | "row_id": { | ||
| 1176 | "name": "row_id", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": true, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "tenant": { | ||
| 1182 | "name": "tenant", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | } | ||
| 1187 | }, | ||
| 1188 | "indexes": { | ||
| 1189 | "subject_uidx": { | ||
| 1190 | "name": "subject_uidx", | ||
| 1191 | "columns": [ | ||
| 1192 | { | ||
| 1193 | "expression": "tenant", | ||
| 1194 | "isExpression": false, | ||
| 1195 | "asc": true, | ||
| 1196 | "nulls": "last" | ||
| 1197 | }, | ||
| 1198 | { | ||
| 1199 | "expression": "external_id", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | } | ||
| 1204 | ], | ||
| 1205 | "isUnique": true, | ||
| 1206 | "concurrently": false, | ||
| 1207 | "method": "btree", | ||
| 1208 | "with": {} | ||
| 1209 | } | ||
| 1210 | }, | ||
| 1211 | "foreignKeys": {}, | ||
| 1212 | "compositePrimaryKeys": {}, | ||
| 1213 | "uniqueConstraints": {}, | ||
| 1214 | "policies": {}, | ||
| 1215 | "checkConstraints": {}, | ||
| 1216 | "isRLSEnabled": false | ||
| 1217 | }, | ||
| 1218 | "public.tool": { | ||
| 1219 | "name": "tool", | ||
| 1220 | "schema": "", | ||
| 1221 | "columns": { | ||
| 1222 | "integration": { | ||
| 1223 | "name": "integration", | ||
| 1224 | "type": "varchar(255)", | ||
| 1225 | "primaryKey": false, | ||
| 1226 | "notNull": true | ||
| 1227 | }, | ||
| 1228 | "connection": { | ||
| 1229 | "name": "connection", | ||
| 1230 | "type": "varchar(255)", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "plugin_id": { | ||
| 1235 | "name": "plugin_id", | ||
| 1236 | "type": "text", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "name": { | ||
| 1241 | "name": "name", | ||
| 1242 | "type": "varchar(255)", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "description": { | ||
| 1247 | "name": "description", | ||
| 1248 | "type": "text", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": true | ||
| 1251 | }, | ||
| 1252 | "input_schema": { | ||
| 1253 | "name": "input_schema", | ||
| 1254 | "type": "json", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": false | ||
| 1257 | }, | ||
| 1258 | "output_schema": { | ||
| 1259 | "name": "output_schema", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "annotations": { | ||
| 1265 | "name": "annotations", | ||
| 1266 | "type": "json", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": false | ||
| 1269 | }, | ||
| 1270 | "created_at": { | ||
| 1271 | "name": "created_at", | ||
| 1272 | "type": "timestamp", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "updated_at": { | ||
| 1277 | "name": "updated_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "row_id": { | ||
| 1283 | "name": "row_id", | ||
| 1284 | "type": "varchar(255)", | ||
| 1285 | "primaryKey": true, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "tenant": { | ||
| 1289 | "name": "tenant", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": false, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "owner": { | ||
| 1295 | "name": "owner", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "subject": { | ||
| 1301 | "name": "subject", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | } | ||
| 1306 | }, | ||
| 1307 | "indexes": { | ||
| 1308 | "tool_uidx": { | ||
| 1309 | "name": "tool_uidx", | ||
| 1310 | "columns": [ | ||
| 1311 | { | ||
| 1312 | "expression": "tenant", | ||
| 1313 | "isExpression": false, | ||
| 1314 | "asc": true, | ||
| 1315 | "nulls": "last" | ||
| 1316 | }, | ||
| 1317 | { | ||
| 1318 | "expression": "owner", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "subject", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "integration", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "connection", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | }, | ||
| 1341 | { | ||
| 1342 | "expression": "name", | ||
| 1343 | "isExpression": false, | ||
| 1344 | "asc": true, | ||
| 1345 | "nulls": "last" | ||
| 1346 | } | ||
| 1347 | ], | ||
| 1348 | "isUnique": true, | ||
| 1349 | "concurrently": false, | ||
| 1350 | "method": "btree", | ||
| 1351 | "with": {} | ||
| 1352 | } | ||
| 1353 | }, | ||
| 1354 | "foreignKeys": {}, | ||
| 1355 | "compositePrimaryKeys": {}, | ||
| 1356 | "uniqueConstraints": {}, | ||
| 1357 | "policies": {}, | ||
| 1358 | "checkConstraints": {}, | ||
| 1359 | "isRLSEnabled": false | ||
| 1360 | }, | ||
| 1361 | "public.tool_policy": { | ||
| 1362 | "name": "tool_policy", | ||
| 1363 | "schema": "", | ||
| 1364 | "columns": { | ||
| 1365 | "id": { | ||
| 1366 | "name": "id", | ||
| 1367 | "type": "varchar(255)", | ||
| 1368 | "primaryKey": false, | ||
| 1369 | "notNull": true | ||
| 1370 | }, | ||
| 1371 | "pattern": { | ||
| 1372 | "name": "pattern", | ||
| 1373 | "type": "text", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "action": { | ||
| 1378 | "name": "action", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "position": { | ||
| 1384 | "name": "position", | ||
| 1385 | "type": "text", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "created_at": { | ||
| 1390 | "name": "created_at", | ||
| 1391 | "type": "timestamp", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "updated_at": { | ||
| 1396 | "name": "updated_at", | ||
| 1397 | "type": "timestamp", | ||
| 1398 | "primaryKey": false, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "row_id": { | ||
| 1402 | "name": "row_id", | ||
| 1403 | "type": "varchar(255)", | ||
| 1404 | "primaryKey": true, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "tenant": { | ||
| 1408 | "name": "tenant", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": false, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "owner": { | ||
| 1414 | "name": "owner", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | }, | ||
| 1419 | "subject": { | ||
| 1420 | "name": "subject", | ||
| 1421 | "type": "varchar(255)", | ||
| 1422 | "primaryKey": false, | ||
| 1423 | "notNull": true | ||
| 1424 | } | ||
| 1425 | }, | ||
| 1426 | "indexes": { | ||
| 1427 | "tool_policy_uidx": { | ||
| 1428 | "name": "tool_policy_uidx", | ||
| 1429 | "columns": [ | ||
| 1430 | { | ||
| 1431 | "expression": "tenant", | ||
| 1432 | "isExpression": false, | ||
| 1433 | "asc": true, | ||
| 1434 | "nulls": "last" | ||
| 1435 | }, | ||
| 1436 | { | ||
| 1437 | "expression": "owner", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "subject", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | }, | ||
| 1448 | { | ||
| 1449 | "expression": "id", | ||
| 1450 | "isExpression": false, | ||
| 1451 | "asc": true, | ||
| 1452 | "nulls": "last" | ||
| 1453 | } | ||
| 1454 | ], | ||
| 1455 | "isUnique": true, | ||
| 1456 | "concurrently": false, | ||
| 1457 | "method": "btree", | ||
| 1458 | "with": {} | ||
| 1459 | } | ||
| 1460 | }, | ||
| 1461 | "foreignKeys": {}, | ||
| 1462 | "compositePrimaryKeys": {}, | ||
| 1463 | "uniqueConstraints": {}, | ||
| 1464 | "policies": {}, | ||
| 1465 | "checkConstraints": {}, | ||
| 1466 | "isRLSEnabled": false | ||
| 1467 | } | ||
| 1468 | }, | ||
| 1469 | "enums": {}, | ||
| 1470 | "schemas": {}, | ||
| 1471 | "sequences": {}, | ||
| 1472 | "roles": {}, | ||
| 1473 | "policies": {}, | ||
| 1474 | "views": {}, | ||
| 1475 | "_meta": { | ||
| 1476 | "columns": {}, | ||
| 1477 | "schemas": {}, | ||
| 1478 | "tables": {} | ||
| 1479 | } | ||
| 1480 | } | ||
| @@ -0,0 +1,1486 @@ | |||
| 1 | { | ||
| 2 | "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", | ||
| 3 | "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "bindings": { | ||
| 172 | "name": "bindings", | ||
| 173 | "type": "json", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": false | ||
| 176 | }, | ||
| 177 | "preview": { | ||
| 178 | "name": "preview", | ||
| 179 | "type": "text", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": false | ||
| 182 | }, | ||
| 183 | "created_at": { | ||
| 184 | "name": "created_at", | ||
| 185 | "type": "timestamp", | ||
| 186 | "primaryKey": false, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "updated_at": { | ||
| 190 | "name": "updated_at", | ||
| 191 | "type": "timestamp", | ||
| 192 | "primaryKey": false, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "row_id": { | ||
| 196 | "name": "row_id", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": true, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "tenant": { | ||
| 202 | "name": "tenant", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | }, | ||
| 207 | "owner": { | ||
| 208 | "name": "owner", | ||
| 209 | "type": "varchar(255)", | ||
| 210 | "primaryKey": false, | ||
| 211 | "notNull": true | ||
| 212 | }, | ||
| 213 | "subject": { | ||
| 214 | "name": "subject", | ||
| 215 | "type": "varchar(255)", | ||
| 216 | "primaryKey": false, | ||
| 217 | "notNull": true | ||
| 218 | } | ||
| 219 | }, | ||
| 220 | "indexes": { | ||
| 221 | "artifact_uidx": { | ||
| 222 | "name": "artifact_uidx", | ||
| 223 | "columns": [ | ||
| 224 | { | ||
| 225 | "expression": "tenant", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "owner", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | }, | ||
| 236 | { | ||
| 237 | "expression": "subject", | ||
| 238 | "isExpression": false, | ||
| 239 | "asc": true, | ||
| 240 | "nulls": "last" | ||
| 241 | }, | ||
| 242 | { | ||
| 243 | "expression": "id", | ||
| 244 | "isExpression": false, | ||
| 245 | "asc": true, | ||
| 246 | "nulls": "last" | ||
| 247 | } | ||
| 248 | ], | ||
| 249 | "isUnique": true, | ||
| 250 | "concurrently": false, | ||
| 251 | "method": "btree", | ||
| 252 | "with": {} | ||
| 253 | } | ||
| 254 | }, | ||
| 255 | "foreignKeys": {}, | ||
| 256 | "compositePrimaryKeys": {}, | ||
| 257 | "uniqueConstraints": {}, | ||
| 258 | "policies": {}, | ||
| 259 | "checkConstraints": {}, | ||
| 260 | "isRLSEnabled": false | ||
| 261 | }, | ||
| 262 | "public.blob": { | ||
| 263 | "name": "blob", | ||
| 264 | "schema": "", | ||
| 265 | "columns": { | ||
| 266 | "namespace": { | ||
| 267 | "name": "namespace", | ||
| 268 | "type": "varchar(255)", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "key": { | ||
| 273 | "name": "key", | ||
| 274 | "type": "varchar(255)", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "value": { | ||
| 279 | "name": "value", | ||
| 280 | "type": "text", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": true | ||
| 283 | }, | ||
| 284 | "row_id": { | ||
| 285 | "name": "row_id", | ||
| 286 | "type": "varchar(255)", | ||
| 287 | "primaryKey": true, | ||
| 288 | "notNull": true | ||
| 289 | }, | ||
| 290 | "id": { | ||
| 291 | "name": "id", | ||
| 292 | "type": "varchar(255)", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": true | ||
| 295 | } | ||
| 296 | }, | ||
| 297 | "indexes": { | ||
| 298 | "blob_id_uidx": { | ||
| 299 | "name": "blob_id_uidx", | ||
| 300 | "columns": [ | ||
| 301 | { | ||
| 302 | "expression": "id", | ||
| 303 | "isExpression": false, | ||
| 304 | "asc": true, | ||
| 305 | "nulls": "last" | ||
| 306 | } | ||
| 307 | ], | ||
| 308 | "isUnique": true, | ||
| 309 | "concurrently": false, | ||
| 310 | "method": "btree", | ||
| 311 | "with": {} | ||
| 312 | } | ||
| 313 | }, | ||
| 314 | "foreignKeys": {}, | ||
| 315 | "compositePrimaryKeys": {}, | ||
| 316 | "uniqueConstraints": {}, | ||
| 317 | "policies": {}, | ||
| 318 | "checkConstraints": {}, | ||
| 319 | "isRLSEnabled": false | ||
| 320 | }, | ||
| 321 | "public.connection": { | ||
| 322 | "name": "connection", | ||
| 323 | "schema": "", | ||
| 324 | "columns": { | ||
| 325 | "integration": { | ||
| 326 | "name": "integration", | ||
| 327 | "type": "varchar(255)", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "name": { | ||
| 332 | "name": "name", | ||
| 333 | "type": "varchar(255)", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "template": { | ||
| 338 | "name": "template", | ||
| 339 | "type": "text", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "provider": { | ||
| 344 | "name": "provider", | ||
| 345 | "type": "text", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": true | ||
| 348 | }, | ||
| 349 | "item_ids": { | ||
| 350 | "name": "item_ids", | ||
| 351 | "type": "json", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": true | ||
| 354 | }, | ||
| 355 | "identity_label": { | ||
| 356 | "name": "identity_label", | ||
| 357 | "type": "text", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "description": { | ||
| 362 | "name": "description", | ||
| 363 | "type": "text", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "last_health": { | ||
| 368 | "name": "last_health", | ||
| 369 | "type": "json", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "tools_synced_at": { | ||
| 374 | "name": "tools_synced_at", | ||
| 375 | "type": "bigint", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "oauth_client": { | ||
| 380 | "name": "oauth_client", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "oauth_client_owner": { | ||
| 386 | "name": "oauth_client_owner", | ||
| 387 | "type": "text", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "refresh_item_id": { | ||
| 392 | "name": "refresh_item_id", | ||
| 393 | "type": "text", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "expires_at": { | ||
| 398 | "name": "expires_at", | ||
| 399 | "type": "bigint", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "oauth_scope": { | ||
| 404 | "name": "oauth_scope", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "oauth_token_url": { | ||
| 410 | "name": "oauth_token_url", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": false | ||
| 414 | }, | ||
| 415 | "provider_state": { | ||
| 416 | "name": "provider_state", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": false | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "updated_at": { | ||
| 428 | "name": "updated_at", | ||
| 429 | "type": "timestamp", | ||
| 430 | "primaryKey": false, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "row_id": { | ||
| 434 | "name": "row_id", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": true, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "tenant": { | ||
| 440 | "name": "tenant", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "owner": { | ||
| 446 | "name": "owner", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | }, | ||
| 451 | "subject": { | ||
| 452 | "name": "subject", | ||
| 453 | "type": "varchar(255)", | ||
| 454 | "primaryKey": false, | ||
| 455 | "notNull": true | ||
| 456 | } | ||
| 457 | }, | ||
| 458 | "indexes": { | ||
| 459 | "connection_uidx": { | ||
| 460 | "name": "connection_uidx", | ||
| 461 | "columns": [ | ||
| 462 | { | ||
| 463 | "expression": "tenant", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "owner", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "subject", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "integration", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.definition": { | ||
| 507 | "name": "definition", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "integration": { | ||
| 511 | "name": "integration", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "connection": { | ||
| 517 | "name": "connection", | ||
| 518 | "type": "varchar(255)", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "plugin_id": { | ||
| 523 | "name": "plugin_id", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "name": { | ||
| 529 | "name": "name", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "schema": { | ||
| 535 | "name": "schema", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "created_at": { | ||
| 541 | "name": "created_at", | ||
| 542 | "type": "timestamp", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "row_id": { | ||
| 547 | "name": "row_id", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": true, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "tenant": { | ||
| 553 | "name": "tenant", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | }, | ||
| 558 | "owner": { | ||
| 559 | "name": "owner", | ||
| 560 | "type": "varchar(255)", | ||
| 561 | "primaryKey": false, | ||
| 562 | "notNull": true | ||
| 563 | }, | ||
| 564 | "subject": { | ||
| 565 | "name": "subject", | ||
| 566 | "type": "varchar(255)", | ||
| 567 | "primaryKey": false, | ||
| 568 | "notNull": true | ||
| 569 | } | ||
| 570 | }, | ||
| 571 | "indexes": { | ||
| 572 | "definition_uidx": { | ||
| 573 | "name": "definition_uidx", | ||
| 574 | "columns": [ | ||
| 575 | { | ||
| 576 | "expression": "tenant", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "owner", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "subject", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "integration", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | }, | ||
| 599 | { | ||
| 600 | "expression": "connection", | ||
| 601 | "isExpression": false, | ||
| 602 | "asc": true, | ||
| 603 | "nulls": "last" | ||
| 604 | }, | ||
| 605 | { | ||
| 606 | "expression": "name", | ||
| 607 | "isExpression": false, | ||
| 608 | "asc": true, | ||
| 609 | "nulls": "last" | ||
| 610 | } | ||
| 611 | ], | ||
| 612 | "isUnique": true, | ||
| 613 | "concurrently": false, | ||
| 614 | "method": "btree", | ||
| 615 | "with": {} | ||
| 616 | } | ||
| 617 | }, | ||
| 618 | "foreignKeys": {}, | ||
| 619 | "compositePrimaryKeys": {}, | ||
| 620 | "uniqueConstraints": {}, | ||
| 621 | "policies": {}, | ||
| 622 | "checkConstraints": {}, | ||
| 623 | "isRLSEnabled": false | ||
| 624 | }, | ||
| 625 | "public.integration": { | ||
| 626 | "name": "integration", | ||
| 627 | "schema": "", | ||
| 628 | "columns": { | ||
| 629 | "slug": { | ||
| 630 | "name": "slug", | ||
| 631 | "type": "varchar(255)", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": true | ||
| 634 | }, | ||
| 635 | "plugin_id": { | ||
| 636 | "name": "plugin_id", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": true | ||
| 640 | }, | ||
| 641 | "name": { | ||
| 642 | "name": "name", | ||
| 643 | "type": "text", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "description": { | ||
| 648 | "name": "description", | ||
| 649 | "type": "text", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "config": { | ||
| 654 | "name": "config", | ||
| 655 | "type": "json", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "health_check": { | ||
| 660 | "name": "health_check", | ||
| 661 | "type": "json", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": false | ||
| 664 | }, | ||
| 665 | "config_revised_at": { | ||
| 666 | "name": "config_revised_at", | ||
| 667 | "type": "bigint", | ||
| 668 | "primaryKey": false, | ||
| 669 | "notNull": false | ||
| 670 | }, | ||
| 671 | "can_remove": { | ||
| 672 | "name": "can_remove", | ||
| 673 | "type": "boolean", | ||
| 674 | "primaryKey": false, | ||
| 675 | "notNull": true, | ||
| 676 | "default": true | ||
| 677 | }, | ||
| 678 | "can_refresh": { | ||
| 679 | "name": "can_refresh", | ||
| 680 | "type": "boolean", | ||
| 681 | "primaryKey": false, | ||
| 682 | "notNull": true, | ||
| 683 | "default": false | ||
| 684 | }, | ||
| 685 | "created_at": { | ||
| 686 | "name": "created_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "updated_at": { | ||
| 692 | "name": "updated_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | } | ||
| 709 | }, | ||
| 710 | "indexes": { | ||
| 711 | "integration_uidx": { | ||
| 712 | "name": "integration_uidx", | ||
| 713 | "columns": [ | ||
| 714 | { | ||
| 715 | "expression": "tenant", | ||
| 716 | "isExpression": false, | ||
| 717 | "asc": true, | ||
| 718 | "nulls": "last" | ||
| 719 | }, | ||
| 720 | { | ||
| 721 | "expression": "slug", | ||
| 722 | "isExpression": false, | ||
| 723 | "asc": true, | ||
| 724 | "nulls": "last" | ||
| 725 | } | ||
| 726 | ], | ||
| 727 | "isUnique": true, | ||
| 728 | "concurrently": false, | ||
| 729 | "method": "btree", | ||
| 730 | "with": {} | ||
| 731 | } | ||
| 732 | }, | ||
| 733 | "foreignKeys": {}, | ||
| 734 | "compositePrimaryKeys": {}, | ||
| 735 | "uniqueConstraints": {}, | ||
| 736 | "policies": {}, | ||
| 737 | "checkConstraints": {}, | ||
| 738 | "isRLSEnabled": false | ||
| 739 | }, | ||
| 740 | "public.oauth_client": { | ||
| 741 | "name": "oauth_client", | ||
| 742 | "schema": "", | ||
| 743 | "columns": { | ||
| 744 | "slug": { | ||
| 745 | "name": "slug", | ||
| 746 | "type": "varchar(255)", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "authorization_url": { | ||
| 751 | "name": "authorization_url", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "token_url": { | ||
| 757 | "name": "token_url", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "grant": { | ||
| 763 | "name": "grant", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_id": { | ||
| 769 | "name": "client_id", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_secret_item_id": { | ||
| 775 | "name": "client_secret_item_id", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "resource": { | ||
| 781 | "name": "resource", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_kind": { | ||
| 787 | "name": "origin_kind", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_integration": { | ||
| 793 | "name": "origin_integration", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "origin_issuer": { | ||
| 799 | "name": "origin_issuer", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "origin_redirect_uri": { | ||
| 805 | "name": "origin_redirect_uri", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "created_at": { | ||
| 811 | "name": "created_at", | ||
| 812 | "type": "timestamp", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "row_id": { | ||
| 817 | "name": "row_id", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": true, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "tenant": { | ||
| 823 | "name": "tenant", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "owner": { | ||
| 829 | "name": "owner", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "subject": { | ||
| 835 | "name": "subject", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": false, | ||
| 838 | "notNull": true | ||
| 839 | } | ||
| 840 | }, | ||
| 841 | "indexes": { | ||
| 842 | "oauth_client_uidx": { | ||
| 843 | "name": "oauth_client_uidx", | ||
| 844 | "columns": [ | ||
| 845 | { | ||
| 846 | "expression": "tenant", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "owner", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | }, | ||
| 857 | { | ||
| 858 | "expression": "subject", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | }, | ||
| 863 | { | ||
| 864 | "expression": "slug", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | } | ||
| 869 | ], | ||
| 870 | "isUnique": true, | ||
| 871 | "concurrently": false, | ||
| 872 | "method": "btree", | ||
| 873 | "with": {} | ||
| 874 | } | ||
| 875 | }, | ||
| 876 | "foreignKeys": {}, | ||
| 877 | "compositePrimaryKeys": {}, | ||
| 878 | "uniqueConstraints": {}, | ||
| 879 | "policies": {}, | ||
| 880 | "checkConstraints": {}, | ||
| 881 | "isRLSEnabled": false | ||
| 882 | }, | ||
| 883 | "public.oauth_session": { | ||
| 884 | "name": "oauth_session", | ||
| 885 | "schema": "", | ||
| 886 | "columns": { | ||
| 887 | "state": { | ||
| 888 | "name": "state", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "client_slug": { | ||
| 894 | "name": "client_slug", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "integration": { | ||
| 900 | "name": "integration", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "name": { | ||
| 906 | "name": "name", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "template": { | ||
| 912 | "name": "template", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "redirect_url": { | ||
| 918 | "name": "redirect_url", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "pkce_verifier": { | ||
| 924 | "name": "pkce_verifier", | ||
| 925 | "type": "text", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": false | ||
| 928 | }, | ||
| 929 | "identity_label": { | ||
| 930 | "name": "identity_label", | ||
| 931 | "type": "text", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": false | ||
| 934 | }, | ||
| 935 | "payload": { | ||
| 936 | "name": "payload", | ||
| 937 | "type": "json", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "expires_at": { | ||
| 942 | "name": "expires_at", | ||
| 943 | "type": "bigint", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "created_at": { | ||
| 948 | "name": "created_at", | ||
| 949 | "type": "timestamp", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "row_id": { | ||
| 954 | "name": "row_id", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": true, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "tenant": { | ||
| 960 | "name": "tenant", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | }, | ||
| 965 | "owner": { | ||
| 966 | "name": "owner", | ||
| 967 | "type": "varchar(255)", | ||
| 968 | "primaryKey": false, | ||
| 969 | "notNull": true | ||
| 970 | }, | ||
| 971 | "subject": { | ||
| 972 | "name": "subject", | ||
| 973 | "type": "varchar(255)", | ||
| 974 | "primaryKey": false, | ||
| 975 | "notNull": true | ||
| 976 | } | ||
| 977 | }, | ||
| 978 | "indexes": { | ||
| 979 | "oauth_session_uidx": { | ||
| 980 | "name": "oauth_session_uidx", | ||
| 981 | "columns": [ | ||
| 982 | { | ||
| 983 | "expression": "tenant", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "state", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.plugin_storage": { | ||
| 1009 | "name": "plugin_storage", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "plugin_id": { | ||
| 1013 | "name": "plugin_id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "collection": { | ||
| 1019 | "name": "collection", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "key": { | ||
| 1025 | "name": "key", | ||
| 1026 | "type": "varchar(255)", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "data": { | ||
| 1031 | "name": "data", | ||
| 1032 | "type": "json", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "created_at": { | ||
| 1037 | "name": "created_at", | ||
| 1038 | "type": "timestamp", | ||
| 1039 | "primaryKey": false, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "updated_at": { | ||
| 1043 | "name": "updated_at", | ||
| 1044 | "type": "timestamp", | ||
| 1045 | "primaryKey": false, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "row_id": { | ||
| 1049 | "name": "row_id", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": true, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "tenant": { | ||
| 1055 | "name": "tenant", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | }, | ||
| 1060 | "owner": { | ||
| 1061 | "name": "owner", | ||
| 1062 | "type": "varchar(255)", | ||
| 1063 | "primaryKey": false, | ||
| 1064 | "notNull": true | ||
| 1065 | }, | ||
| 1066 | "subject": { | ||
| 1067 | "name": "subject", | ||
| 1068 | "type": "varchar(255)", | ||
| 1069 | "primaryKey": false, | ||
| 1070 | "notNull": true | ||
| 1071 | } | ||
| 1072 | }, | ||
| 1073 | "indexes": { | ||
| 1074 | "plugin_storage_uidx": { | ||
| 1075 | "name": "plugin_storage_uidx", | ||
| 1076 | "columns": [ | ||
| 1077 | { | ||
| 1078 | "expression": "tenant", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "owner", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "subject", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "plugin_id", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | }, | ||
| 1101 | { | ||
| 1102 | "expression": "collection", | ||
| 1103 | "isExpression": false, | ||
| 1104 | "asc": true, | ||
| 1105 | "nulls": "last" | ||
| 1106 | }, | ||
| 1107 | { | ||
| 1108 | "expression": "key", | ||
| 1109 | "isExpression": false, | ||
| 1110 | "asc": true, | ||
| 1111 | "nulls": "last" | ||
| 1112 | } | ||
| 1113 | ], | ||
| 1114 | "isUnique": true, | ||
| 1115 | "concurrently": false, | ||
| 1116 | "method": "btree", | ||
| 1117 | "with": {} | ||
| 1118 | } | ||
| 1119 | }, | ||
| 1120 | "foreignKeys": {}, | ||
| 1121 | "compositePrimaryKeys": {}, | ||
| 1122 | "uniqueConstraints": {}, | ||
| 1123 | "policies": {}, | ||
| 1124 | "checkConstraints": {}, | ||
| 1125 | "isRLSEnabled": false | ||
| 1126 | }, | ||
| 1127 | "public.private_executor_cloud_settings": { | ||
| 1128 | "name": "private_executor_cloud_settings", | ||
| 1129 | "schema": "", | ||
| 1130 | "columns": { | ||
| 1131 | "id": { | ||
| 1132 | "name": "id", | ||
| 1133 | "type": "varchar(255)", | ||
| 1134 | "primaryKey": true, | ||
| 1135 | "notNull": true | ||
| 1136 | }, | ||
| 1137 | "version": { | ||
| 1138 | "name": "version", | ||
| 1139 | "type": "varchar(255)", | ||
| 1140 | "primaryKey": false, | ||
| 1141 | "notNull": true, | ||
| 1142 | "default": "'1.0.0'" | ||
| 1143 | } | ||
| 1144 | }, | ||
| 1145 | "indexes": {}, | ||
| 1146 | "foreignKeys": {}, | ||
| 1147 | "compositePrimaryKeys": {}, | ||
| 1148 | "uniqueConstraints": {}, | ||
| 1149 | "policies": {}, | ||
| 1150 | "checkConstraints": {}, | ||
| 1151 | "isRLSEnabled": false | ||
| 1152 | }, | ||
| 1153 | "public.subject": { | ||
| 1154 | "name": "subject", | ||
| 1155 | "schema": "", | ||
| 1156 | "columns": { | ||
| 1157 | "external_id": { | ||
| 1158 | "name": "external_id", | ||
| 1159 | "type": "varchar(255)", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "created_at": { | ||
| 1164 | "name": "created_at", | ||
| 1165 | "type": "timestamp", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": true | ||
| 1168 | }, | ||
| 1169 | "last_seen_at": { | ||
| 1170 | "name": "last_seen_at", | ||
| 1171 | "type": "bigint", | ||
| 1172 | "primaryKey": false, | ||
| 1173 | "notNull": false | ||
| 1174 | }, | ||
| 1175 | "status": { | ||
| 1176 | "name": "status", | ||
| 1177 | "type": "text", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": false | ||
| 1180 | }, | ||
| 1181 | "row_id": { | ||
| 1182 | "name": "row_id", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": true, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "tenant": { | ||
| 1188 | "name": "tenant", | ||
| 1189 | "type": "varchar(255)", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | } | ||
| 1193 | }, | ||
| 1194 | "indexes": { | ||
| 1195 | "subject_uidx": { | ||
| 1196 | "name": "subject_uidx", | ||
| 1197 | "columns": [ | ||
| 1198 | { | ||
| 1199 | "expression": "tenant", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | }, | ||
| 1204 | { | ||
| 1205 | "expression": "external_id", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nulls": "last" | ||
| 1209 | } | ||
| 1210 | ], | ||
| 1211 | "isUnique": true, | ||
| 1212 | "concurrently": false, | ||
| 1213 | "method": "btree", | ||
| 1214 | "with": {} | ||
| 1215 | } | ||
| 1216 | }, | ||
| 1217 | "foreignKeys": {}, | ||
| 1218 | "compositePrimaryKeys": {}, | ||
| 1219 | "uniqueConstraints": {}, | ||
| 1220 | "policies": {}, | ||
| 1221 | "checkConstraints": {}, | ||
| 1222 | "isRLSEnabled": false | ||
| 1223 | }, | ||
| 1224 | "public.tool": { | ||
| 1225 | "name": "tool", | ||
| 1226 | "schema": "", | ||
| 1227 | "columns": { | ||
| 1228 | "integration": { | ||
| 1229 | "name": "integration", | ||
| 1230 | "type": "varchar(255)", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "connection": { | ||
| 1235 | "name": "connection", | ||
| 1236 | "type": "varchar(255)", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "plugin_id": { | ||
| 1241 | "name": "plugin_id", | ||
| 1242 | "type": "text", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "name": { | ||
| 1247 | "name": "name", | ||
| 1248 | "type": "varchar(255)", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": true | ||
| 1251 | }, | ||
| 1252 | "description": { | ||
| 1253 | "name": "description", | ||
| 1254 | "type": "text", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": true | ||
| 1257 | }, | ||
| 1258 | "input_schema": { | ||
| 1259 | "name": "input_schema", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "output_schema": { | ||
| 1265 | "name": "output_schema", | ||
| 1266 | "type": "json", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": false | ||
| 1269 | }, | ||
| 1270 | "annotations": { | ||
| 1271 | "name": "annotations", | ||
| 1272 | "type": "json", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": false | ||
| 1275 | }, | ||
| 1276 | "created_at": { | ||
| 1277 | "name": "created_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "updated_at": { | ||
| 1283 | "name": "updated_at", | ||
| 1284 | "type": "timestamp", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "row_id": { | ||
| 1289 | "name": "row_id", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": true, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "tenant": { | ||
| 1295 | "name": "tenant", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "owner": { | ||
| 1301 | "name": "owner", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | }, | ||
| 1306 | "subject": { | ||
| 1307 | "name": "subject", | ||
| 1308 | "type": "varchar(255)", | ||
| 1309 | "primaryKey": false, | ||
| 1310 | "notNull": true | ||
| 1311 | } | ||
| 1312 | }, | ||
| 1313 | "indexes": { | ||
| 1314 | "tool_uidx": { | ||
| 1315 | "name": "tool_uidx", | ||
| 1316 | "columns": [ | ||
| 1317 | { | ||
| 1318 | "expression": "tenant", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "owner", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "subject", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "integration", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | }, | ||
| 1341 | { | ||
| 1342 | "expression": "connection", | ||
| 1343 | "isExpression": false, | ||
| 1344 | "asc": true, | ||
| 1345 | "nulls": "last" | ||
| 1346 | }, | ||
| 1347 | { | ||
| 1348 | "expression": "name", | ||
| 1349 | "isExpression": false, | ||
| 1350 | "asc": true, | ||
| 1351 | "nulls": "last" | ||
| 1352 | } | ||
| 1353 | ], | ||
| 1354 | "isUnique": true, | ||
| 1355 | "concurrently": false, | ||
| 1356 | "method": "btree", | ||
| 1357 | "with": {} | ||
| 1358 | } | ||
| 1359 | }, | ||
| 1360 | "foreignKeys": {}, | ||
| 1361 | "compositePrimaryKeys": {}, | ||
| 1362 | "uniqueConstraints": {}, | ||
| 1363 | "policies": {}, | ||
| 1364 | "checkConstraints": {}, | ||
| 1365 | "isRLSEnabled": false | ||
| 1366 | }, | ||
| 1367 | "public.tool_policy": { | ||
| 1368 | "name": "tool_policy", | ||
| 1369 | "schema": "", | ||
| 1370 | "columns": { | ||
| 1371 | "id": { | ||
| 1372 | "name": "id", | ||
| 1373 | "type": "varchar(255)", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "pattern": { | ||
| 1378 | "name": "pattern", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "action": { | ||
| 1384 | "name": "action", | ||
| 1385 | "type": "text", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "position": { | ||
| 1390 | "name": "position", | ||
| 1391 | "type": "text", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "created_at": { | ||
| 1396 | "name": "created_at", | ||
| 1397 | "type": "timestamp", | ||
| 1398 | "primaryKey": false, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "updated_at": { | ||
| 1402 | "name": "updated_at", | ||
| 1403 | "type": "timestamp", | ||
| 1404 | "primaryKey": false, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "row_id": { | ||
| 1408 | "name": "row_id", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": true, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "tenant": { | ||
| 1414 | "name": "tenant", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | }, | ||
| 1419 | "owner": { | ||
| 1420 | "name": "owner", | ||
| 1421 | "type": "varchar(255)", | ||
| 1422 | "primaryKey": false, | ||
| 1423 | "notNull": true | ||
| 1424 | }, | ||
| 1425 | "subject": { | ||
| 1426 | "name": "subject", | ||
| 1427 | "type": "varchar(255)", | ||
| 1428 | "primaryKey": false, | ||
| 1429 | "notNull": true | ||
| 1430 | } | ||
| 1431 | }, | ||
| 1432 | "indexes": { | ||
| 1433 | "tool_policy_uidx": { | ||
| 1434 | "name": "tool_policy_uidx", | ||
| 1435 | "columns": [ | ||
| 1436 | { | ||
| 1437 | "expression": "tenant", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "owner", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | }, | ||
| 1448 | { | ||
| 1449 | "expression": "subject", | ||
| 1450 | "isExpression": false, | ||
| 1451 | "asc": true, | ||
| 1452 | "nulls": "last" | ||
| 1453 | }, | ||
| 1454 | { | ||
| 1455 | "expression": "id", | ||
| 1456 | "isExpression": false, | ||
| 1457 | "asc": true, | ||
| 1458 | "nulls": "last" | ||
| 1459 | } | ||
| 1460 | ], | ||
| 1461 | "isUnique": true, | ||
| 1462 | "concurrently": false, | ||
| 1463 | "method": "btree", | ||
| 1464 | "with": {} | ||
| 1465 | } | ||
| 1466 | }, | ||
| 1467 | "foreignKeys": {}, | ||
| 1468 | "compositePrimaryKeys": {}, | ||
| 1469 | "uniqueConstraints": {}, | ||
| 1470 | "policies": {}, | ||
| 1471 | "checkConstraints": {}, | ||
| 1472 | "isRLSEnabled": false | ||
| 1473 | } | ||
| 1474 | }, | ||
| 1475 | "enums": {}, | ||
| 1476 | "schemas": {}, | ||
| 1477 | "sequences": {}, | ||
| 1478 | "roles": {}, | ||
| 1479 | "policies": {}, | ||
| 1480 | "views": {}, | ||
| 1481 | "_meta": { | ||
| 1482 | "columns": {}, | ||
| 1483 | "schemas": {}, | ||
| 1484 | "tables": {} | ||
| 1485 | } | ||
| 1486 | } | ||
| @@ -71,6 +71,48 @@ · 70 unmodified lines | |||
| 71 | "when": 1783025157233, | 71 | "when": 1783025157233, |
| 72 | "tag": "0009_true_lake", | 72 | "tag": "0009_true_lake", |
| 73 | "breakpoints": true | 73 | "breakpoints": true |
| 74 | }, | ||
| 75 | { | ||
| 76 | "idx": 10, | ||
| 77 | "version": "7", | ||
| 78 | "when": 1783660669921, | ||
| 79 | "tag": "0010_ndjson_output_arrays", | ||
| 80 | "breakpoints": true | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | "idx": 11, | ||
| 84 | "version": "7", | ||
| 85 | "when": 1784581232732, | ||
| 86 | "tag": "0011_tense_caretaker", | ||
| 87 | "breakpoints": true | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | "idx": 12, | ||
| 91 | "version": "7", | ||
| 92 | "when": 1785193313601, | ||
| 93 | "tag": "0012_woozy_shiva", | ||
| 94 | "breakpoints": true | ||
| 95 | }, | ||
| 96 | { | ||
| 97 | "idx": 13, | ||
| 98 | "version": "7", | ||
| 99 | "when": 1785354087138, | ||
| 100 | "tag": "0013_curly_rocket_racer", | ||
| 101 | "breakpoints": true | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | "idx": 14, | ||
| 105 | "version": "7", | ||
| 106 | "when": 1785355312973, | ||
| 107 | "tag": "0014_new_karma", | ||
| 108 | "breakpoints": true | ||
| 109 | }, | ||
| 110 | { | ||
| 111 | "idx": 15, | ||
| 112 | "version": "7", | ||
| 113 | "when": 1785355354955, | ||
| 114 | "tag": "0015_equal_the_leader", | ||
| 115 | "breakpoints": true | ||
| 74 | } | 116 | } |
| 75 | ] | 117 | ] |
| 76 | } | 118 | } |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; | 13 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines | |||
| 42 | * falls back to the credential-driven default. */ | 48 | * falls back to the credential-driven default. */ |
| 43 | readonly workosVaultClient?: WorkOSVaultClient; | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | readonly activeToolkitSlug?: string; | 50 | readonly activeToolkitSlug?: string; |
| 45 | /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets | ||
| 46 | * `microsoft.addGraph` point at a loopback emulator instead of the pinned | ||
| 47 | * Microsoft Graph URLs. Off by default; production leaves it unset. */ | ||
| 48 | readonly allowLocalNetwork?: boolean; | ||
| 49 | } | 51 | } |
| 50 | 52 | ||
| 51 | export default defineExecutorConfig({ | 53 | export default defineExecutorConfig({ |
| 52 | plugins: ({ | 54 | plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => |
| 53 | workosCredentials, | ||
| 54 | workosVaultClient, | ||
| 55 | activeToolkitSlug, | ||
| 56 | allowLocalNetwork, | ||
| 57 | }: CloudPluginDeps = {}) => | ||
| 58 | [ | 55 | [ |
| 59 | openApiHttpPlugin(), | 56 | openApiHttpPlugin({ |
| 60 | googleHttpPlugin(), | 57 | presets: [...googleCatalog, ...microsoftCatalog], |
| 61 | microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | 58 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 59 | }), | ||
| 62 | mcpHttpPlugin({ | 60 | mcpHttpPlugin({ |
| 63 | dangerouslyAllowStdioMCP: false, | 61 | dangerouslyAllowStdioMCP: false, |
| 64 | }), | 62 | }), |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/cloud", | 2 | "name": "@executor-js/cloud", |
| 3 | "version": "1.4.47", | 3 | "version": "1.4.61", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -30,7 +30,10 @@ · 23 unmodified lines | |||
| 30 | "typecheck:slow": "tsc --noEmit", | 30 | "typecheck:slow": "tsc --noEmit", |
| 31 | "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", | 31 | "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", |
| 32 | "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", | 32 | "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", |
| 33 | "routes:gen": "bun scripts/gen-routes.ts" | 33 | "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", |
| 34 | "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", | ||
| 35 | "routes:gen": "bun scripts/gen-routes.ts", | ||
| 36 | "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" | ||
| 34 | }, | 37 | }, |
| 35 | "dependencies": { | 38 | "dependencies": { |
| 36 | "@cloudflare/vite-plugin": "^1.31.1", | 39 | "@cloudflare/vite-plugin": "^1.31.1", |
| @@ -41,10 +44,9 @@ · 4 unmodified lines | |||
| 41 | "@executor-js/execution": "workspace:*", | 44 | "@executor-js/execution": "workspace:*", |
| 42 | "@executor-js/fumadb": "workspace:*", | 45 | "@executor-js/fumadb": "workspace:*", |
| 43 | "@executor-js/host-mcp": "workspace:*", | 46 | "@executor-js/host-mcp": "workspace:*", |
| 44 | "@executor-js/plugin-google": "workspace:*", | 47 | "@executor-js/mcp-apps-shell": "workspace:*", |
| 45 | "@executor-js/plugin-graphql": "workspace:*", | 48 | "@executor-js/plugin-graphql": "workspace:*", |
| 46 | "@executor-js/plugin-mcp": "workspace:*", | 49 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 48 | "@executor-js/plugin-openapi": "workspace:*", | 50 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | "@executor-js/plugin-toolkits": "workspace:*", | 51 | "@executor-js/plugin-toolkits": "workspace:*", |
| 50 | "@executor-js/plugin-workos-vault": "workspace:*", | 52 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -53,6 +55,7 @@ · 2 unmodified lines | |||
| 53 | "@executor-js/runtime-quickjs": "workspace:*", | 55 | "@executor-js/runtime-quickjs": "workspace:*", |
| 54 | "@executor-js/sdk": "workspace:*", | 56 | "@executor-js/sdk": "workspace:*", |
| 55 | "@executor-js/vite-plugin": "workspace:*", | 57 | "@executor-js/vite-plugin": "workspace:*", |
| 58 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | ||
| 56 | "@modelcontextprotocol/sdk": "^1.29.0", | 59 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 57 | "@opentelemetry/api": "~1.9.0", | 60 | "@opentelemetry/api": "~1.9.0", |
| 58 | "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", | 61 | "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", |
| @@ -70,9 +73,11 @@ · 11 unmodified lines | |||
| 70 | "autumn-js": "^1.2.8", | 73 | "autumn-js": "^1.2.8", |
| 71 | "drizzle-orm": "catalog:", | 74 | "drizzle-orm": "catalog:", |
| 72 | "effect": "catalog:", | 75 | "effect": "catalog:", |
| 76 | "iron-webcrypto": "2.0.0", | ||
| 73 | "jose": "^5.6.3", | 77 | "jose": "^5.6.3", |
| 74 | "postgres": "^3.4.9", | 78 | "postgres": "^3.4.9", |
| 75 | "posthog-js": "^1.372.5", | 79 | "posthog-js": "^1.372.5", |
| 80 | "quickjs-emscripten-core": "0.31.0", | ||
| 76 | "react": "catalog:", | 81 | "react": "catalog:", |
| 77 | "react-dom": "catalog:", | 82 | "react-dom": "catalog:", |
| 78 | "sonner": "^2.0.7" | 83 | "sonner": "^2.0.7" |
| @@ -0,0 +1,87 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ | ||
| 2 | // --------------------------------------------------------------------------- | ||
| 3 | // One-off data backfill: mint `subject` rows for principals that predate the | ||
| 4 | // subject table (added by migration 0012, written only on sightings from then | ||
| 5 | // on). | ||
| 6 | // | ||
| 7 | // bun run db:backfill-subjects:prod # op run --env-file=.env.production | ||
| 8 | // bun run db:backfill-subjects:dev # against the local PGlite dev db | ||
| 9 | // | ||
| 10 | // Source of truth is the `connection` table: before 0012, "which users exist | ||
| 11 | // under this tenant" was answerable only through connection rows, so a distinct | ||
| 12 | // (tenant, subject) there — excluding the org sentinel '' — is exactly the set | ||
| 13 | // of users the admin console under-reports until their next request. | ||
| 14 | // | ||
| 15 | // created_at ← the tenant+subject's oldest connection, the closest honest | ||
| 16 | // answer to "when did this user first appear". | ||
| 17 | // last_seen_at ← the tenant+subject's newest connection `updated_at`, the | ||
| 18 | // latest activity storage can attest to. Rendering every | ||
| 19 | // pre-0012 user as "Never" was literally true of the sighting | ||
| 20 | // column but reads as "inactive user" to an operator, which | ||
| 21 | // is the wrong answer for a tenant full of active people. The | ||
| 22 | // first real sighting overwrites this with request truth. | ||
| 23 | // | ||
| 24 | // Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique | ||
| 25 | // index leaves rows the runtime has since minted (or a prior run inserted) | ||
| 26 | // untouched, and the last_seen_at repair only fills NULLs — so re-running is | ||
| 27 | // safe and never rewinds a live row's fields. | ||
| 28 | // Pass --dry-run to print the plan without writing. | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | import postgres from "postgres"; | ||
| 32 | import { createId } from "@executor-js/fumadb/cuid"; | ||
| 33 | |||
| 34 | const dryRun = process.argv.includes("--dry-run"); | ||
| 35 | |||
| 36 | const connectionString = process.env.DATABASE_URL; | ||
| 37 | if (!connectionString) { | ||
| 38 | console.error("DATABASE_URL is not set"); | ||
| 39 | process.exit(1); | ||
| 40 | } | ||
| 41 | |||
| 42 | const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); | ||
| 43 | |||
| 44 | const rows = await sql< | ||
| 45 | { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[] | ||
| 46 | >` | ||
| 47 | SELECT tenant, subject, | ||
| 48 | MIN(created_at) AS first_connection_at, | ||
| 49 | MAX(updated_at) AS last_activity_at | ||
| 50 | FROM connection | ||
| 51 | WHERE subject <> '' | ||
| 52 | GROUP BY tenant, subject | ||
| 53 | ORDER BY tenant, subject | ||
| 54 | `; | ||
| 55 | console.log(`${rows.length} (tenant, subject) pair(s) with connections`); | ||
| 56 | |||
| 57 | let inserted = 0; | ||
| 58 | let repaired = 0; | ||
| 59 | for (const row of rows) { | ||
| 60 | console.log( | ||
| 61 | `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`, | ||
| 62 | ); | ||
| 63 | if (dryRun) continue; | ||
| 64 | const lastSeenMs = row.last_activity_at.getTime(); | ||
| 65 | const result = await sql` | ||
| 66 | INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) | ||
| 67 | VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL) | ||
| 68 | ON CONFLICT (tenant, external_id) DO NOTHING | ||
| 69 | `; | ||
| 70 | inserted += result.count; | ||
| 71 | if (result.count > 0) continue; | ||
| 72 | // The row predates this run but may still carry NULL from a run of the | ||
| 73 | // pre-repair version of this script. Fill it in; a sighted row keeps its | ||
| 74 | // request-truth timestamp. | ||
| 75 | const repair = await sql` | ||
| 76 | UPDATE subject SET last_seen_at = ${lastSeenMs} | ||
| 77 | WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL | ||
| 78 | `; | ||
| 79 | repaired += repair.count; | ||
| 80 | } | ||
| 81 | |||
| 82 | console.log( | ||
| 83 | dryRun | ||
| 84 | ? `dry run — would insert up to ${rows.length} row(s)` | ||
| 85 | : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`, | ||
| 86 | ); | ||
| 87 | await sql.end(); | ||
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { spawnSync } from "node:child_process"; | 10 | import { spawnSync } from "node:child_process"; |
| 11 | import { randomBytes } from "node:crypto"; | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { rmSync } from "node:fs"; | 12 | import { existsSync, readFileSync, rmSync } from "node:fs"; |
| 13 | 13 | ||
| 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { | 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { |
| 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); | 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); |
| @@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines | |||
| 32 | process.exit(result.status ?? 1); | 32 | process.exit(result.status ?? 1); |
| 33 | } | 33 | } |
| 34 | } | 34 | } |
| 35 | |||
| 36 | // The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`) | ||
| 37 | // by fetching this stable-named asset through the ASSETS binding — it has no | ||
| 38 | // filesystem to read the shell from. A build that failed to emit it would | ||
| 39 | // deploy a Worker whose artifact resource reads all fail, so make the miss a | ||
| 40 | // build failure here instead of a production incident. Same shape as the | ||
| 41 | // worker-bundler asset assertion from #1378. | ||
| 42 | const shellAsset = new URL( | ||
| 43 | "../dist/client/assets/executor-mcp-apps-shell-stable.html", | ||
| 44 | import.meta.url, | ||
| 45 | ); | ||
| 46 | if (!existsSync(shellAsset)) { | ||
| 47 | console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); | ||
| 48 | process.exit(1); | ||
| 49 | } | ||
| 50 | if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { | ||
| 51 | console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); | ||
| 52 | process.exit(1); | ||
| 53 | } | ||
| @@ -15,6 +15,7 @@ import { PGlite } from "@electric-sql/pglite"; · 14 unmodified lines | |||
| 15 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; | 15 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| 16 | import { drizzle } from "drizzle-orm/pglite"; | 16 | import { drizzle } from "drizzle-orm/pglite"; |
| 17 | import { migrate } from "drizzle-orm/pglite/migrator"; | 17 | import { migrate } from "drizzle-orm/pglite/migrator"; |
| 18 | import postgres from "postgres"; | ||
| 18 | 19 | ||
| 19 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 20 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 20 | // Port + data dir default to the dev values but are env-overridable so a second | 21 | // Port + data dir default to the dev values but are env-overridable so a second |
| @@ -104,25 +105,46 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); · 83 unmodified lines | |||
| 104 | // but prepared statement requires M" -> random 500s on whichever request lost | 105 | // but prepared statement requires M" -> random 500s on whichever request lost |
| 105 | // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch | 106 | // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch |
| 106 | // batches each socket data event into one queue entry and holds handler | 107 | // batches each socket data event into one queue entry and holds handler |
| 107 | // affinity while a pipeline is open; | 108 | // affinity while a pipeline is open. The patch also fixes the queue's two |
| 108 | // src/db/dev-db-socket-concurrency.node.test.ts is the regression test. | 109 | // self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn: |
| 109 | const server = new PGLiteSocketServer({ | 110 | // callback set no session (500)" cascade, where new connections' startup |
| 110 | db, | 111 | // packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart: |
| 111 | port: PORT, | 112 | // 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at |
| 112 | host: "127.0.0.1", | 113 | // the JS level, leaving its `processing` flag latched true; nothing was |
| 113 | maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), | 114 | // ever dequeued again. |
| 114 | // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse | 115 | // 2. A client whose socket died WHILE its pipeline-opening entry executed: |
| 115 | // sent, no Sync) with its socket still OPEN would hold the queue's handler | 116 | // detach() cleared affinity before the entry finished, the queue then |
| 116 | // affinity forever and starve every other connection, since affinity only | 117 | // took affinity for the already-dead handler, and no timer was left to |
| 117 | // releases on detach and detach needs close/error/idle-timeout. In ms; the | 118 | // release it. The queue now tracks detached handlers and repairs any |
| 118 | // timer resets on every data event, so only a genuinely dead client trips it. | 119 | // transaction or pipeline affinity they can no longer release. |
| 119 | idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), | 120 | // src/db/dev-db-socket-concurrency.node.test.ts is the regression test for |
| 120 | }); | 121 | // all of the above. |
| 122 | const makeServer = () => | ||
| 123 | new PGLiteSocketServer({ | ||
| 124 | db, | ||
| 125 | port: PORT, | ||
| 126 | host: "127.0.0.1", | ||
| 127 | maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), | ||
| 128 | // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse | ||
| 129 | // sent, no Sync) with its socket still OPEN would hold the queue's handler | ||
| 130 | // affinity forever and starve every other connection, since affinity only | ||
| 131 | // releases on detach and detach needs close/error/idle-timeout. In ms; the | ||
| 132 | // timer resets on every data event. The patch scopes the reap to connections | ||
| 133 | // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest | ||
| 134 | // connection is the normal state of a healthy postgres.js pool held by a | ||
| 135 | // long-lived scope (SSE), and reaping those raced live queries into | ||
| 136 | // sporadic `write CONNECTION_ENDED` 500s. | ||
| 137 | idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), | ||
| 138 | }); | ||
| 121 | 139 | ||
| 140 | let server = makeServer(); | ||
| 122 | await server.start(); | 141 | await server.start(); |
| 123 | console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); | 142 | console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); |
| 124 | 143 | ||
| 144 | let stopping = false; | ||
| 145 | |||
| 125 | const shutdown = async () => { | 146 | const shutdown = async () => { |
| 147 | stopping = true; | ||
| 126 | console.log("\n[dev-db] Shutting down"); | 148 | console.log("\n[dev-db] Shutting down"); |
| 127 | await server.stop(); | 149 | await server.stop(); |
| 128 | await db.close(); | 150 | await db.close(); |
| @@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines | |||
| 131 | 153 | ||
| 132 | process.on("SIGINT", shutdown); | 154 | process.on("SIGINT", shutdown); |
| 133 | process.on("SIGTERM", shutdown); | 155 | process.on("SIGTERM", shutdown); |
| 156 | |||
| 157 | // --------------------------------------------------------------------------- | ||
| 158 | // Wedge watchdog | ||
| 159 | // --------------------------------------------------------------------------- | ||
| 160 | // | ||
| 161 | // Twice the socket server has shipped a state machine that could stop | ||
| 162 | // answering NEW connections while the process, the port, and PGlite all stayed | ||
| 163 | // up (the CI e2e "cloud signIn: callback set no session (500)" cascades: every | ||
| 164 | // in-flight query dies once, then every fresh connection's startup packet | ||
| 165 | // times out — CONNECT_TIMEOUT — for the rest of the shard). The known paths | ||
| 166 | // are patched with regression tests, but each recurrence so far has found a | ||
| 167 | // new path, and a wedged front-end turns ONE infra hiccup into a failure of | ||
| 168 | // every remaining test in the shard. | ||
| 169 | // | ||
| 170 | // So: probe the server the way the app does — a fresh TCP connection, real | ||
| 171 | // startup handshake, `select 1` — and when several consecutive probes fail, | ||
| 172 | // dump the server's internals to the boot log and swap in a fresh socket | ||
| 173 | // server on the same PGlite instance (all state is in PGlite; the front-end is | ||
| 174 | // stateless, so this drops only already-doomed connections). If a restart | ||
| 175 | // doesn't restore service, exit non-zero: the boot supervisor logs the exit | ||
| 176 | // loudly and the run fails fast with an attributable cause instead of minutes | ||
| 177 | // of anonymous CONNECT_TIMEOUTs. The wedge itself stays visible in the | ||
| 178 | // server-logs artifact via the [dev-db][watchdog] lines. | ||
| 179 | const WATCHDOG_INTERVAL_MS = Number(process.env.DEV_DB_WATCHDOG_INTERVAL_MS ?? 5_000); | ||
| 180 | // 3 consecutive failures ≈ 15s+ of hard unavailability. PGlite serves queries | ||
| 181 | // in milliseconds; even a deep queue clears in well under one probe interval, | ||
| 182 | // so consecutive startup failures this sustained only happen wedged. | ||
| 183 | const WATCHDOG_FAILURES_TO_RESTART = 3; | ||
| 184 | const WATCHDOG_MAX_RESTARTS = 3; | ||
| 185 | |||
| 186 | const probe = async (): Promise<void> => { | ||
| 187 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { | ||
| 188 | max: 1, | ||
| 189 | idle_timeout: 0, | ||
| 190 | connect_timeout: 5, | ||
| 191 | fetch_types: false, | ||
| 192 | prepare: false, | ||
| 193 | onnotice: () => undefined, | ||
| 194 | }); | ||
| 195 | try { | ||
| 196 | // connect_timeout only bounds the handshake; race the query too so a | ||
| 197 | // post-startup wedge cannot hang the watchdog itself. | ||
| 198 | await Promise.race([ | ||
| 199 | sql.unsafe("select 1"), | ||
| 200 | sleep(10_000).then(() => { | ||
| 201 | throw new Error("probe query timed out after 10s"); | ||
| 202 | }), | ||
| 203 | ]); | ||
| 204 | } finally { | ||
| 205 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 206 | } | ||
| 207 | }; | ||
| 208 | |||
| 209 | const watchdog = async () => { | ||
| 210 | let consecutiveFailures = 0; | ||
| 211 | let restarts = 0; | ||
| 212 | for (;;) { | ||
| 213 | await sleep(WATCHDOG_INTERVAL_MS); | ||
| 214 | if (stopping) return; | ||
| 215 | try { | ||
| 216 | await probe(); | ||
| 217 | consecutiveFailures = 0; | ||
| 218 | } catch (cause) { | ||
| 219 | consecutiveFailures += 1; | ||
| 220 | console.error( | ||
| 221 | `[dev-db][watchdog] probe failed (${consecutiveFailures}/${WATCHDOG_FAILURES_TO_RESTART}): ${String(cause)}`, | ||
| 222 | ); | ||
| 223 | if (consecutiveFailures < WATCHDOG_FAILURES_TO_RESTART) continue; | ||
| 224 | console.error( | ||
| 225 | `[dev-db][watchdog] socket server wedged; stats: ${JSON.stringify(server.getStats())}`, | ||
| 226 | ); | ||
| 227 | if (restarts >= WATCHDOG_MAX_RESTARTS) { | ||
| 228 | console.error( | ||
| 229 | `[dev-db][watchdog] still wedged after ${restarts} restarts — giving up so the boot supervisor reports it`, | ||
| 230 | ); | ||
| 231 | process.exit(1); | ||
| 232 | } | ||
| 233 | restarts += 1; | ||
| 234 | consecutiveFailures = 0; | ||
| 235 | console.error( | ||
| 236 | `[dev-db][watchdog] restarting socket server (${restarts}/${WATCHDOG_MAX_RESTARTS})`, | ||
| 237 | ); | ||
| 238 | // stop() itself goes through the query queue (detach rolls back open | ||
| 239 | // transactions), so a wedge deep enough can hang the restart too — | ||
| 240 | // bound it and treat that as fatal rather than hanging the watchdog. | ||
| 241 | const restart = async () => { | ||
| 242 | await server.stop(); | ||
| 243 | server = makeServer(); | ||
| 244 | await server.start(); | ||
| 245 | }; | ||
| 246 | try { | ||
| 247 | await Promise.race([ | ||
| 248 | restart(), | ||
| 249 | sleep(15_000).then(() => { | ||
| 250 | throw new Error("restart timed out after 15s"); | ||
| 251 | }), | ||
| 252 | ]); | ||
| 253 | console.error(`[dev-db][watchdog] socket server restarted`); | ||
| 254 | } catch (restartCause) { | ||
| 255 | console.error( | ||
| 256 | `[dev-db][watchdog] restart failed (${String(restartCause)}) — exiting so the boot supervisor reports it`, | ||
| 257 | ); | ||
| 258 | process.exit(1); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | }; | ||
| 263 | |||
| 264 | void watchdog(); | ||
| @@ -0,0 +1,124 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Measure the Worker's *evaluated* module closures from the build output. | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // Upload size does not predict cold-isolate cost: a Worker can ship megabytes | ||
| 6 | // that never load, and the bytes that matter are the ones an isolate must | ||
| 7 | // evaluate before it can answer. Rollup already separates static from dynamic | ||
| 8 | // edges, so we can compute that split directly instead of reading totals. | ||
| 9 | // | ||
| 10 | // Two closures matter: | ||
| 11 | // startup - statically reachable from the Worker entry. Evaluated on every | ||
| 12 | // cold isolate before any request is served. | ||
| 13 | // start - the TanStack Start server graph, reached through the lazy | ||
| 14 | // `loadEntries` dynamic imports. Evaluated on the first request | ||
| 15 | // that enters the app - i.e. a page request. | ||
| 16 | // app - the Effect app plane. `/api/*` dispatches at the Worker entry and | ||
| 17 | // skips Start entirely, so an API request evaluates this instead of | ||
| 18 | // `start`; reported separately because the two planes now diverge. | ||
| 19 | // | ||
| 20 | // Anything reachable only through a dynamic import is not counted: making a | ||
| 21 | // heavy dependency lazy is exactly the outcome this rewards. | ||
| 22 | // | ||
| 23 | // Scope note: this measures bytes, which is a proxy for cold-start cost, not a | ||
| 24 | // proven cause of any particular regression. The Aug 2026 MCP incident was | ||
| 25 | // NOT explained by this number - reverting the offending packages moved the | ||
| 26 | // evaluated closure by 0.02 MB while restoring production latency, so | ||
| 27 | // module-scope execution cost, not size, drove that one. Treat a budget breach | ||
| 28 | // as "this will make cold starts worse", not as "this is why the site is slow". | ||
| 29 | import { readFileSync, readdirSync, statSync } from "node:fs"; | ||
| 30 | import { dirname, join, relative, resolve } from "node:path"; | ||
| 31 | |||
| 32 | const DIST = resolve(process.argv[2] ?? "dist/server"); | ||
| 33 | const ENTRY = join(DIST, "index.js"); | ||
| 34 | |||
| 35 | // Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"` | ||
| 36 | // (all static) and `import("./x.js")` (dynamic). Matching the emitted output | ||
| 37 | // rather than source means we see the graph as the runtime sees it. | ||
| 38 | const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g; | ||
| 39 | const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g; | ||
| 40 | |||
| 41 | const listChunks = (dir) => | ||
| 42 | readdirSync(dir, { withFileTypes: true }).flatMap((e) => | ||
| 43 | e.isDirectory() | ||
| 44 | ? listChunks(join(dir, e.name)) | ||
| 45 | : e.name.endsWith(".js") | ||
| 46 | ? [join(dir, e.name)] | ||
| 47 | : [], | ||
| 48 | ); | ||
| 49 | |||
| 50 | const graph = new Map(); | ||
| 51 | for (const file of listChunks(DIST)) { | ||
| 52 | const code = readFileSync(file, "utf8"); | ||
| 53 | const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1]))); | ||
| 54 | // A specifier inside `import(...)` also matches STATIC_RE's `import` branch, | ||
| 55 | // so subtract the dynamic set rather than trusting the static matches alone. | ||
| 56 | const staticDeps = new Set( | ||
| 57 | [...code.matchAll(STATIC_RE)] | ||
| 58 | .map((m) => resolve(dirname(file), m[1])) | ||
| 59 | .filter((p) => !dynamic.has(p)), | ||
| 60 | ); | ||
| 61 | graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic }); | ||
| 62 | } | ||
| 63 | |||
| 64 | /** Bytes evaluated when `roots` are loaded, following static edges only. */ | ||
| 65 | const closure = (roots) => { | ||
| 66 | const seen = new Set(); | ||
| 67 | const queue = [...roots]; | ||
| 68 | while (queue.length > 0) { | ||
| 69 | const file = queue.pop(); | ||
| 70 | if (seen.has(file) || !graph.has(file)) continue; | ||
| 71 | seen.add(file); | ||
| 72 | queue.push(...graph.get(file).static); | ||
| 73 | } | ||
| 74 | return seen; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0); | ||
| 78 | const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`; | ||
| 79 | const name = (f) => relative(DIST, f); | ||
| 80 | |||
| 81 | const startup = closure([ENTRY]); | ||
| 82 | // The lazy server-graph entries Start pulls on first request. | ||
| 83 | const startRoots = [...graph.get(ENTRY).dynamic].filter((f) => | ||
| 84 | /(start|router|tanstack)/.test(name(f)), | ||
| 85 | ); | ||
| 86 | const start = closure(startRoots); | ||
| 87 | const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f)); | ||
| 88 | const app = closure([ENTRY, ...appRoots]); | ||
| 89 | // The budget tracks the worst plane: whichever costs a cold isolate more. | ||
| 90 | const evaluated = new Set([...startup, ...start]); | ||
| 91 | |||
| 92 | const report = (label, files) => { | ||
| 93 | console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`); | ||
| 94 | const own = [...files].filter((f) => !startup.has(f) || label === "startup"); | ||
| 95 | for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) { | ||
| 96 | console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`); | ||
| 97 | } | ||
| 98 | }; | ||
| 99 | |||
| 100 | report("startup", startup); | ||
| 101 | report("start", start); | ||
| 102 | console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`); | ||
| 103 | console.log( | ||
| 104 | `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`, | ||
| 105 | ); | ||
| 106 | const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f)); | ||
| 107 | console.log( | ||
| 108 | `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`, | ||
| 109 | ); | ||
| 110 | |||
| 111 | const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0); | ||
| 112 | if (budget > 0) { | ||
| 113 | const actual = bytes(evaluated) / 1024 / 1024; | ||
| 114 | console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`); | ||
| 115 | if (actual > budget) { | ||
| 116 | console.error( | ||
| 117 | `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` + | ||
| 118 | `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` + | ||
| 119 | `new weight behind a dynamic import rather than raising the budget; run this\n` + | ||
| 120 | `script with no budget set to see the biggest members and what is already lazy.`, | ||
| 121 | ); | ||
| 122 | process.exit(1); | ||
| 123 | } | ||
| 124 | } | ||
| @@ -13,12 +13,26 @@ import { fileURLToPath } from "node:url"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 15 | 15 | ||
| 16 | const PORT = 5434; | 16 | const parsePort = (input: string | undefined): number => { |
| 17 | if (input === undefined) return 5434; | ||
| 18 | if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer"); | ||
| 19 | const port = Number(input); | ||
| 20 | if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { | ||
| 21 | throw new Error("CLOUD_TEST_DB_PORT must be between 1 and 65535"); | ||
| 22 | } | ||
| 23 | return port; | ||
| 24 | }; | ||
| 25 | |||
| 26 | const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT); | ||
| 17 | const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); | 27 | const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); |
| 18 | 28 | ||
| 19 | let db: PGlite | undefined; | 29 | let db: PGlite | undefined; |
| 20 | let server: PGLiteSocketServer | undefined; | 30 | let server: PGLiteSocketServer | undefined; |
| 21 | 31 | ||
| 32 | /** | ||
| 33 | * Starts the cloud unit-test database and returns teardown that releases every | ||
| 34 | * resource without allowing PGlite shutdown to erase Vitest's failure status. | ||
| 35 | */ | ||
| 22 | export default async function setup() { | 36 | export default async function setup() { |
| 23 | db = await PGlite.create(); | 37 | db = await PGlite.create(); |
| 24 | await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); | 38 | await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); |
| @@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines | |||
| 30 | console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); | 44 | console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); |
| 31 | 45 | ||
| 32 | return async () => { | 46 | return async () => { |
| 47 | // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on | ||
| 48 | // close. Preserve Vitest's failure status across that close so | ||
| 49 | // teardown can turn neither a red test green nor a green test red. | ||
| 50 | const testsFailed = process.exitCode === 1; | ||
| 33 | await server?.stop(); | 51 | await server?.stop(); |
| 34 | await db?.close(); | 52 | await db?.close(); |
| 53 | if (testsFailed) process.exitCode = 1; | ||
| 35 | }; | 54 | }; |
| 36 | } | 55 | } |
| @@ -0,0 +1,18 @@ | |||
| 1 | // Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module | ||
| 2 | // rule (rooted at the app dir) can statically compile it at build time. Workers | ||
| 3 | // forbid runtime WASM compilation, and the rule's glob won't reach the | ||
| 4 | // monorepo-root node_modules, so the bytes must live inside this app. | ||
| 5 | // | ||
| 6 | // Re-run after bumping @jitl/quickjs-wasmfile-release-sync: | ||
| 7 | // bun run scripts/vendor-quickjs-wasm.ts | ||
| 8 | import { copyFileSync } from "node:fs"; | ||
| 9 | import { createRequire } from "node:module"; | ||
| 10 | import { dirname, join } from "node:path"; | ||
| 11 | import { fileURLToPath } from "node:url"; | ||
| 12 | |||
| 13 | const require = createRequire(import.meta.url); | ||
| 14 | const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); | ||
| 15 | const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); | ||
| 16 | |||
| 17 | copyFileSync(source, dest); | ||
| 18 | console.log(`vendored ${source} -> ${dest}`); | ||
| @@ -0,0 +1,195 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { AccountProvider } from "@executor-js/api/server"; | ||
| 5 | import { AccountError, AccountForbidden } from "@executor-js/api"; | ||
| 6 | |||
| 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; | ||
| 8 | import { UserStoreService } from "../auth/context"; | ||
| 9 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; | ||
| 10 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | ||
| 11 | import { AutumnService } from "../extensions/billing/service"; | ||
| 12 | import { AccountCaller, workosAccountProvider } from "./workos-account-service"; | ||
| 13 | |||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | // `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary. | ||
| 16 | // | ||
| 17 | // An org key reads every user and connection in the tenant through `/admin/*`, | ||
| 18 | // so it is the most privileged credential the product issues. Two properties | ||
| 19 | // have to hold together, and only this seam sees both: | ||
| 20 | // | ||
| 21 | // 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A | ||
| 22 | // plain member calling it gets 403 and no delete goes out. | ||
| 23 | // 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the | ||
| 24 | // personal path) checks the caller's OWN key list, so it can never revoke | ||
| 25 | // an org key; conversely this path must never destroy a member's personal | ||
| 26 | // key. The scope-blind `deleteApiKey` would do exactly that if handed a | ||
| 27 | // user key id, so a wrong id has to fail cleanly instead. | ||
| 28 | // | ||
| 29 | // Ownership filtering itself is pinned per-shape in | ||
| 30 | // `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through | ||
| 31 | // the gate, on the provider the HTTP handler actually calls. | ||
| 32 | // --------------------------------------------------------------------------- | ||
| 33 | |||
| 34 | const ORG = "org_123"; | ||
| 35 | const ADMIN = "user_admin"; | ||
| 36 | const MEMBER = "user_member"; | ||
| 37 | const ORG_KEY = "key_org_1"; | ||
| 38 | const USER_KEY = "key_user_1"; | ||
| 39 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | ||
| 40 | const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG }; | ||
| 41 | |||
| 42 | const session = (accountId: string) => ({ | ||
| 43 | accountId, | ||
| 44 | email: `${accountId}@example.test`, | ||
| 45 | name: null, | ||
| 46 | avatarUrl: null, | ||
| 47 | organizationId: ORG, | ||
| 48 | sealedSession: "sealed", | ||
| 49 | refreshedSession: null, | ||
| 50 | }); | ||
| 51 | |||
| 52 | /** Membership roles: only ADMIN carries the `admin` role slug. */ | ||
| 53 | const stubWorkOS = Layer.succeed( | ||
| 54 | WorkOSClient, | ||
| 55 | new Proxy({} as WorkOSClientService, { | ||
| 56 | get: (_target, prop) => { | ||
| 57 | if (prop === "listUserMemberships") { | ||
| 58 | return (userId: string) => | ||
| 59 | Effect.succeed({ | ||
| 60 | data: [{ userId, organizationId: ORG, status: "active" }], | ||
| 61 | }); | ||
| 62 | } | ||
| 63 | if (prop === "getUserOrgMembership") { | ||
| 64 | return (organizationId: string, userId: string) => | ||
| 65 | Effect.succeed( | ||
| 66 | organizationId === ORG | ||
| 67 | ? { | ||
| 68 | id: `om_${userId}`, | ||
| 69 | userId, | ||
| 70 | organizationId, | ||
| 71 | role: { slug: userId === ADMIN ? "admin" : "member" }, | ||
| 72 | } | ||
| 73 | : null, | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 77 | }, | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const stubUsers = Layer.succeed(UserStoreService)({ | ||
| 82 | use: (_op, fn) => | ||
| 83 | Effect.promise(() => | ||
| 84 | fn({ | ||
| 85 | ensureAccount: async (id: string) => ({ id, createdAt }), | ||
| 86 | getAccount: async (id: string) => ({ id, createdAt }), | ||
| 87 | upsertOrganization: async (org: { id: string; name: string }) => ({ | ||
| 88 | ...org, | ||
| 89 | slug: org.id, | ||
| 90 | createdAt, | ||
| 91 | }), | ||
| 92 | getOrganization: async (id: string) => ({ | ||
| 93 | id, | ||
| 94 | name: `Org ${id}`, | ||
| 95 | slug: id, | ||
| 96 | createdAt, | ||
| 97 | }), | ||
| 98 | getOrganizationBySlug: async (slug: string) => ({ | ||
| 99 | id: slug, | ||
| 100 | name: `Org ${slug}`, | ||
| 101 | slug, | ||
| 102 | createdAt, | ||
| 103 | }), | ||
| 104 | deleteOrganizationCascade: async () => {}, | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | }); | ||
| 108 | |||
| 109 | const stubAutumn = Layer.succeed(AutumnService)({ | ||
| 110 | use: () => Effect.die("revoke does not touch billing"), | ||
| 111 | ensureCustomer: () => Effect.die("revoke does not touch billing"), | ||
| 112 | checkExecutionBalance: () => Effect.die("revoke does not touch billing"), | ||
| 113 | trackExecution: () => Effect.void, | ||
| 114 | }); | ||
| 115 | |||
| 116 | /** | ||
| 117 | * The provider under test, over an `ApiKeyService` whose org listing holds a | ||
| 118 | * single org-owned key. Revoked ids land in `revoked` so "no delete happened" | ||
| 119 | * is assertable, not merely inferred from the error. | ||
| 120 | */ | ||
| 121 | const providerWith = (accountId: string) => { | ||
| 122 | const revoked: string[] = []; | ||
| 123 | const stubApiKeys = Layer.succeed(ApiKeyService)({ | ||
| 124 | validate: () => Effect.die("revoke test does not validate keys"), | ||
| 125 | listUserKeys: () => Effect.succeed([]), | ||
| 126 | createUserKey: () => Effect.die("revoke test does not create user keys"), | ||
| 127 | revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"), | ||
| 128 | listOrgKeys: () => Effect.die("revoke test does not list org keys"), | ||
| 129 | createOrgKey: () => Effect.die("revoke test does not create org keys"), | ||
| 130 | revokeOrgKey: ({ keyId }) => | ||
| 131 | keyId === ORG_KEY | ||
| 132 | ? Effect.sync(() => { | ||
| 133 | revoked.push(keyId); | ||
| 134 | }) | ||
| 135 | : Effect.fail(new OrgApiKeyNotFound({ keyId })), | ||
| 136 | }); | ||
| 137 | |||
| 138 | const provider = AccountProvider.asEffect().pipe( | ||
| 139 | Effect.provide( | ||
| 140 | workosAccountProvider.pipe( | ||
| 141 | Layer.provide( | ||
| 142 | Layer.mergeAll( | ||
| 143 | stubWorkOS, | ||
| 144 | stubUsers, | ||
| 145 | stubApiKeys, | ||
| 146 | stubAutumn, | ||
| 147 | Layer.succeed(AccountCaller)({ session: session(accountId) }), | ||
| 148 | ), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | ), | ||
| 152 | ); | ||
| 153 | |||
| 154 | return { provider, revoked }; | ||
| 155 | }; | ||
| 156 | |||
| 157 | describe("revokeOrgApiKey · provider boundary", () => { | ||
| 158 | it.effect("an org admin revokes an org-owned key", () => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const { provider, revoked } = providerWith(ADMIN); | ||
| 161 | const account = yield* provider; | ||
| 162 | |||
| 163 | const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY); | ||
| 164 | |||
| 165 | expect(result).toEqual({ success: true }); | ||
| 166 | expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 170 | it.effect("a plain member is forbidden, and nothing is revoked", () => | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const { provider, revoked } = providerWith(MEMBER); | ||
| 173 | const account = yield* provider; | ||
| 174 | |||
| 175 | const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY)); | ||
| 176 | |||
| 177 | expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); | ||
| 178 | expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | |||
| 182 | it.effect("a user-scoped key id fails cleanly through this path", () => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | // Not found — not a 500, and emphatically not a silent success that would | ||
| 185 | // report a member's live personal credential as destroyed. | ||
| 186 | const { provider, revoked } = providerWith(ADMIN); | ||
| 187 | const account = yield* provider; | ||
| 188 | |||
| 189 | const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY)); | ||
| 190 | |||
| 191 | expect(error).toBeInstanceOf(AccountError); | ||
| 192 | expect(revoked).toEqual([]); | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | }); | ||
| @@ -91,16 +91,19 @@ export const workosAccountProvider: Layer.Layer< · 90 unmodified lines | |||
| 91 | ? Effect.succeed(caller.session) | 91 | ? Effect.succeed(caller.session) |
| 92 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); | 92 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); |
| 93 | 93 | ||
| 94 | // The org scope for an org-scoped request: the console URL's org (sent in | 94 | // The org scope for an org-scoped request: the console URL's org, sent in |
| 95 | // the selector header) when present, else the session's own org. Membership | 95 | // the selector header. FAIL CLOSED when it's missing — the session's own |
| 96 | // is re-checked live, so the header is a selector, not a trust boundary — | 96 | // org is a browser-global pinned to whichever org WorkOS last touched, so |
| 97 | // falling back to it scopes a multi-org user's request to the WRONG org | ||
| 98 | // (see workos-auth-provider.resolveSessionPrincipal). Membership is | ||
| 99 | // re-checked live, so the header is a selector, not a trust boundary — | ||
| 97 | // and two browser tabs on different orgs each send their own header, so | 100 | // and two browser tabs on different orgs each send their own header, so |
| 98 | // they stay independent (see organization.ts). Yields the session + | 101 | // they stay independent (see organization.ts). Yields the session + |
| 99 | // resolved org, or AccountNoOrganization. | 102 | // resolved org, or AccountNoOrganization. |
| 100 | const requireOrganization = (headers: AccountHeaders) => | 103 | const requireOrganization = (headers: AccountHeaders) => |
| 101 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 102 | const session = yield* requireSession(); | 105 | const session = yield* requireSession(); |
| 103 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | 106 | const selector = headers[ORG_SELECTOR_HEADER]; |
| 104 | if (!selector) { | 107 | if (!selector) { |
| 105 | return yield* new AccountNoOrganization(); | 108 | return yield* new AccountNoOrganization(); |
| 106 | } | 109 | } |
| @@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines | |||
| 180 | me: (headers) => | 183 | me: (headers) => |
| 181 | Effect.gen(function* () { | 184 | Effect.gen(function* () { |
| 182 | const session = yield* requireSession(); | 185 | const session = yield* requireSession(); |
| 183 | // Same selector precedence as requireOrganization: the URL's org | 186 | // The ONE sanctioned fallback to the session org. /account/me is |
| 184 | // (header) drives /account/me so the shell reflects the org the tab | 187 | // identity, not tenant data: on a bare URL (no org slug yet, so no |
| 185 | // is viewing, not a session-global active org. | 188 | // header) it answers "which org should this browser land in", and |
| 189 | // the session's pinned org is the only candidate. Every org-scoped | ||
| 190 | // DATA read fails closed instead (requireOrganization above, | ||
| 191 | // resolveSessionPrincipal) — serving tenant data from this fallback | ||
| 192 | // is exactly the wrong-org connection-list bug (2026-07). With a | ||
| 193 | // header (any slugged URL), the URL's org drives the answer so the | ||
| 194 | // shell reflects the org the tab is viewing. | ||
| 186 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | 195 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; |
| 187 | const org = selector | 196 | const org = selector |
| 188 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( | 197 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| @@ -237,6 +246,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines | |||
| 237 | return { success: true }; | 246 | return { success: true }; |
| 238 | }), | 247 | }), |
| 239 | 248 | ||
| 249 | // Org keys read every user in the tenant through the `/admin/*` plane, so | ||
| 250 | // both paths are admin-gated — unlike personal keys, which any member may | ||
| 251 | // mint for themselves. | ||
| 252 | listOrgApiKeys: (headers) => | ||
| 253 | Effect.gen(function* () { | ||
| 254 | const { session, org } = yield* requireOrganization(headers); | ||
| 255 | yield* requireAdmin(session.accountId, org.id); | ||
| 256 | const keys = yield* apiKeys | ||
| 257 | .listOrgKeys({ organizationId: org.id }) | ||
| 258 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | ||
| 259 | return { apiKeys: keys }; | ||
| 260 | }), | ||
| 261 | |||
| 262 | createOrgApiKey: (headers, name) => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const { session, org } = yield* requireOrganization(headers); | ||
| 265 | yield* requireAdmin(session.accountId, org.id); | ||
| 266 | const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); | ||
| 267 | if (!trimmed) { | ||
| 268 | return yield* new AccountError({ message: "API key name is required" }); | ||
| 269 | } | ||
| 270 | return yield* apiKeys | ||
| 271 | .createOrgKey({ organizationId: org.id, name: trimmed }) | ||
| 272 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | ||
| 273 | }), | ||
| 274 | |||
| 275 | // Same admin gate as the mint. Note this deliberately does NOT reuse | ||
| 276 | // `revokeApiKey`: that path resolves ownership against the caller's | ||
| 277 | // personal keys, so an org key id is never in its owned set. Ownership | ||
| 278 | // here is resolved against the ORG's keys inside `revokeOrgKey`, which is | ||
| 279 | // what keeps a user-scoped (or foreign-org) key id from being destroyed | ||
| 280 | // through the org route — it surfaces as a 404-shaped AccountError, not a | ||
| 281 | // silent success and not a 500. | ||
| 282 | revokeOrgApiKey: (headers, apiKeyId) => | ||
| 283 | Effect.gen(function* () { | ||
| 284 | const { session, org } = yield* requireOrganization(headers); | ||
| 285 | yield* requireAdmin(session.accountId, org.id); | ||
| 286 | yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( | ||
| 287 | Effect.catchTag("ApiKeyManagementError", toAccountError), | ||
| 288 | Effect.catchTag("OrgApiKeyNotFound", () => | ||
| 289 | Effect.fail(new AccountError({ message: "Organization API key not found" })), | ||
| 290 | ), | ||
| 291 | ); | ||
| 292 | return { success: true }; | ||
| 293 | }), | ||
| 294 | |||
| 240 | listMembers: (headers) => | 295 | listMembers: (headers) => |
| 241 | Effect.gen(function* () { | 296 | Effect.gen(function* () { |
| 242 | const { session, org } = yield* requireOrganization(headers); | 297 | const { session, org } = yield* requireOrganization(headers); |
| @@ -331,7 +386,9 @@ export const workosAccountProvider: Layer.Layer< · 88 unmodified lines | |||
| 331 | .updateOrganization(org.id, name) | 386 | .updateOrganization(org.id, name) |
| 332 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 387 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| 333 | yield* users | 388 | yield* users |
| 334 | .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name })) | 389 | .use("upsertOrganization", (s) => |
| 390 | s.upsertOrganization({ id: updated.id, name: updated.name }), | ||
| 391 | ) | ||
| 335 | .pipe(Effect.catchTag("UserStoreError", toAccountError)); | 392 | .pipe(Effect.catchTag("UserStoreError", toAccountError)); |
| 336 | return { name: updated.name }; | 393 | return { name: updated.name }; |
| 337 | }), | 394 | }), |
| @@ -0,0 +1,354 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers` | ||
| 3 | // backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`. | ||
| 4 | // | ||
| 5 | // TWO credentials reach this plane, and they are the two an operator actually | ||
| 6 | // has: | ||
| 7 | // 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS | ||
| 8 | // validated it and reported which org owns it, and there is no member | ||
| 9 | // behind it to check membership for. This is the machine credential | ||
| 10 | // (a customer's backend calling us). | ||
| 11 | // 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership` | ||
| 12 | // whose role slug is `admin` AND whose status is `active`, matching the | ||
| 13 | // strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) — | ||
| 14 | // a pending admin invite is not an admin. | ||
| 15 | // A plain member session, or a USER-scoped api key, is refused: both name one | ||
| 16 | // acting member, and this plane deliberately serves the whole tenant. | ||
| 17 | // | ||
| 18 | // The executor is built by `makePlatformExecutor` — `{ tenant, subject: | ||
| 19 | // undefined, platformView: true }` — so the reads are tenant-wide and read-only | ||
| 20 | // by storage policy, and no `subject` row is minted for the caller. | ||
| 21 | // | ||
| 22 | // Cross-tenant isolation is structural, not a check in this file: the tenant is | ||
| 23 | // taken from the resolved credential (the key's own org, or the session's | ||
| 24 | // authorized org), never from client input, and `reach: "tenant"` still filters | ||
| 25 | // every query by that tenant. | ||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | |||
| 28 | import { env } from "cloudflare:workers"; | ||
| 29 | import { HttpRouter } from "effect/unstable/http"; | ||
| 30 | import { Context, Effect, Layer, Option } from "effect"; | ||
| 31 | |||
| 32 | import { | ||
| 33 | AdminUsersProvider, | ||
| 34 | DbProvider, | ||
| 35 | HostConfig, | ||
| 36 | PluginsProvider, | ||
| 37 | getAdminUser, | ||
| 38 | listAdminUserConnections, | ||
| 39 | listAdminUsers, | ||
| 40 | listAdminUsersWithConnections, | ||
| 41 | makeAdminUsersApiLayer, | ||
| 42 | makePlatformExecutor, | ||
| 43 | normalizeAdminUserEmail, | ||
| 44 | platformViewOf, | ||
| 45 | requestScopedMiddleware, | ||
| 46 | type AdminEmailResolver, | ||
| 47 | type AdminIdentityDirectory, | ||
| 48 | type AdminUserDirectory, | ||
| 49 | type AdminUserIdentity, | ||
| 50 | type AdminUsersHeaders, | ||
| 51 | } from "@executor-js/api/server"; | ||
| 52 | import { | ||
| 53 | AdminUserNotFound, | ||
| 54 | AdminUsersError, | ||
| 55 | AdminUsersForbidden, | ||
| 56 | AdminUsersUnauthorized, | ||
| 57 | } from "@executor-js/api"; | ||
| 58 | import type { Executor } from "@executor-js/sdk"; | ||
| 59 | |||
| 60 | import { ApiKeyService } from "../auth/api-keys"; | ||
| 61 | import { UserStoreService } from "../auth/context"; | ||
| 62 | import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; | ||
| 63 | import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; | ||
| 64 | import { WorkOSClient } from "../auth/workos"; | ||
| 65 | import { DbService } from "../db/db"; | ||
| 66 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Resolve the tenant this request may read, or fail with the neutral 401/403. | ||
| 70 | * | ||
| 71 | * Returns only the organization id: nothing downstream needs to know WHICH of | ||
| 72 | * the two credentials got the caller here, and keeping the acting member out of | ||
| 73 | * the return value means no admin read can accidentally become subject-scoped. | ||
| 74 | */ | ||
| 75 | const authorizeTenant = ( | ||
| 76 | request: Request, | ||
| 77 | ): Effect.Effect< | ||
| 78 | string, | ||
| 79 | AdminUsersUnauthorized | AdminUsersForbidden, | ||
| 80 | WorkOSClient | ApiKeyService | UserStoreService | ||
| 81 | > => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, | ||
| 84 | // which rejects org keys for the product plane) is what distinguishes an | ||
| 85 | // org key from a user key. | ||
| 86 | const bearer = yield* resolveBearerAuth(request).pipe( | ||
| 87 | // Every rejected-credential and infra failure collapses to one refusal: | ||
| 88 | // this plane must not report whether a key exists, belongs to another | ||
| 89 | // org, or merely lacks privilege. | ||
| 90 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 91 | ); | ||
| 92 | if (bearer !== null) { | ||
| 93 | if (isPlatformAuth(bearer)) return bearer.organizationId; | ||
| 94 | // A user-scoped key authenticated fine but names one member; the platform | ||
| 95 | // plane has no honest way to serve it. | ||
| 96 | return yield* new AdminUsersForbidden(); | ||
| 97 | } | ||
| 98 | |||
| 99 | // (2) The session path: a live admin membership in the selected org. | ||
| 100 | const workos = yield* WorkOSClient; | ||
| 101 | const session = yield* workos | ||
| 102 | .authenticateRequest(request) | ||
| 103 | .pipe(Effect.catchCause(() => Effect.succeed(null))); | ||
| 104 | if (!session) return yield* new AdminUsersUnauthorized(); | ||
| 105 | |||
| 106 | const selector = orgSelectorFromRequest(request) ?? session.organizationId; | ||
| 107 | if (!selector) return yield* new AdminUsersForbidden(); | ||
| 108 | // Re-checks live membership, so the org selector header can only ever name | ||
| 109 | // an org the caller already belongs to. | ||
| 110 | const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe( | ||
| 111 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 112 | ); | ||
| 113 | if (!org) return yield* new AdminUsersForbidden(); | ||
| 114 | |||
| 115 | const membership = yield* workos | ||
| 116 | .getUserOrgMembership(org.id, session.userId) | ||
| 117 | .pipe(Effect.catchCause(() => Effect.succeed(null))); | ||
| 118 | // A pending admin invite is not an active admin — require both. | ||
| 119 | if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { | ||
| 120 | return yield* new AdminUsersForbidden(); | ||
| 121 | } | ||
| 122 | return org.id; | ||
| 123 | }); | ||
| 124 | |||
| 125 | /** | ||
| 126 | * How many user-detail reads run at once. Matches the account plane's own | ||
| 127 | * member listing (`workos-account-service.ts`), which fans out the same way for | ||
| 128 | * the same reason. | ||
| 129 | */ | ||
| 130 | const IDENTITY_CONCURRENCY = 5; | ||
| 131 | |||
| 132 | /** | ||
| 133 | * Cloud's member directory: `externalId` → email/name. | ||
| 134 | * | ||
| 135 | * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that | ||
| 136 | * `workos-auth-provider.ts` binds as `accountId` on every credential path, and | ||
| 137 | * therefore what the subject table records in `external_id`. The membership's | ||
| 138 | * own `id` is an `om_...` row id and joins to nothing. | ||
| 139 | * | ||
| 140 | * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per | ||
| 141 | * request and is the authority on who belongs to the org, but WorkOS's | ||
| 142 | * `listOrganizationMemberships` carries no user detail and offers no | ||
| 143 | * include/expand — email and name only exist on the user resource. The SDK does | ||
| 144 | * expose a batched `listUsers({ organizationId })`, but the pinned | ||
| 145 | * `@executor-js/emulate` WorkOS emulator serves only `GET | ||
| 146 | * /user_management/users/:id`, so taking that path would leave every cloud e2e | ||
| 147 | * user unnamed. So: ONE membership read per request, then user detail fetched | ||
| 148 | * only for the ids ON THIS PAGE — never for the whole org, and never once per | ||
| 149 | * row of some larger list. An id that is not an active/pending member is not | ||
| 150 | * fetched at all and reports absent identity, which is the honest answer for a | ||
| 151 | * member who left while their connections remain. | ||
| 152 | */ | ||
| 153 | const identityDirectory = | ||
| 154 | (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory => | ||
| 155 | (externalIds) => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | const workos = yield* WorkOSClient; | ||
| 158 | const memberships = yield* workos.listOrgMembers(organizationId); | ||
| 159 | const wanted = new Set(externalIds); | ||
| 160 | const memberIds = memberships.data | ||
| 161 | .map((membership) => membership.userId) | ||
| 162 | .filter((userId) => wanted.has(userId)); | ||
| 163 | |||
| 164 | const resolved = yield* Effect.all( | ||
| 165 | memberIds.map((userId) => | ||
| 166 | workos.getUser(userId).pipe( | ||
| 167 | Effect.map( | ||
| 168 | (user) => | ||
| 169 | [ | ||
| 170 | userId, | ||
| 171 | { | ||
| 172 | email: user.email, | ||
| 173 | displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, | ||
| 174 | }, | ||
| 175 | ] as const, | ||
| 176 | ), | ||
| 177 | // One unreadable user must not cost the whole page its names. | ||
| 178 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 179 | ), | ||
| 180 | ), | ||
| 181 | { concurrency: IDENTITY_CONCURRENCY }, | ||
| 182 | ); | ||
| 183 | |||
| 184 | const identities = new Map<string, AdminUserIdentity>(); | ||
| 185 | for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); | ||
| 186 | return identities; | ||
| 187 | }).pipe(Effect.provideContext(context)); | ||
| 188 | |||
| 189 | /** | ||
| 190 | * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. | ||
| 191 | * | ||
| 192 | * Production asks WorkOS for the email AND organization in one request. Both | ||
| 193 | * filters matter: email makes the lookup indexed rather than one `getUser` | ||
| 194 | * request per member, while organization keeps the reverse lookup bound to the | ||
| 195 | * same tenant as the platform view. | ||
| 196 | * | ||
| 197 | * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route. | ||
| 198 | * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path | ||
| 199 | * retains the membership scan until the emulator supports the production | ||
| 200 | * query. The fallback still starts from the tenant's membership list and can | ||
| 201 | * never return a user from another organization. | ||
| 202 | * | ||
| 203 | * CASING: WorkOS preserves whatever casing an email was created with (and the | ||
| 204 | * emulator compares byte-exact), so the directory value is normalized here | ||
| 205 | * before comparison, against an argument the seam already normalized. | ||
| 206 | */ | ||
| 207 | export const emailResolver = | ||
| 208 | (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver => | ||
| 209 | (email) => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const workos = yield* WorkOSClient; | ||
| 212 | |||
| 213 | if (!env.WORKOS_API_URL) { | ||
| 214 | const users = yield* workos.listUsers({ email, organizationId }); | ||
| 215 | return users.data[0]?.id ?? null; | ||
| 216 | } | ||
| 217 | |||
| 218 | const memberships = yield* workos.listOrgMembers(organizationId); | ||
| 219 | const userIds = memberships.data.map((membership) => membership.userId); | ||
| 220 | |||
| 221 | // Emulator compatibility only. Short-circuit once the normalized email | ||
| 222 | // matches so the fallback makes as few unsupported-detail reads as it can. | ||
| 223 | const match = yield* Effect.findFirst(userIds, (userId) => | ||
| 224 | workos.getUser(userId).pipe( | ||
| 225 | Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), | ||
| 226 | // One unreadable user must not fail the whole lookup — it simply | ||
| 227 | // cannot be the match. | ||
| 228 | Effect.catchCause(() => Effect.succeed(false)), | ||
| 229 | ), | ||
| 230 | ); | ||
| 231 | return Option.getOrNull(match); | ||
| 232 | }).pipe(Effect.provideContext(context)); | ||
| 233 | |||
| 234 | /** Both directions of cloud's directory, built once per authorized request. */ | ||
| 235 | const userDirectory = ( | ||
| 236 | organizationId: string, | ||
| 237 | context: Context.Context<WorkOSClient>, | ||
| 238 | ): AdminUserDirectory => ({ | ||
| 239 | identities: identityDirectory(organizationId, context), | ||
| 240 | resolveEmail: emailResolver(organizationId, context), | ||
| 241 | }); | ||
| 242 | |||
| 243 | /** | ||
| 244 | * Authorize, then run `body` against the tenant's platform view. | ||
| 245 | * | ||
| 246 | * The executor is built per request and closed after, like every other cloud | ||
| 247 | * execution stack: it holds the per-request postgres socket, which Cloudflare's | ||
| 248 | * I/O isolation forbids sharing across requests. | ||
| 249 | */ | ||
| 250 | const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( | ||
| 251 | headers: AdminUsersHeaders, | ||
| 252 | body: (executor: Executor, organizationId: string) => Effect.Effect<A, E>, | ||
| 253 | ): Effect.Effect< | ||
| 254 | A, | ||
| 255 | // `AdminUsersError` unconditionally: opening the platform view can fail that | ||
| 256 | // way regardless of what `body` itself raises. | ||
| 257 | E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden, | ||
| 258 | WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | ||
| 259 | > => | ||
| 260 | Effect.gen(function* () { | ||
| 261 | const organizationId = yield* authorizeTenant( | ||
| 262 | new Request("https://admin.invalid", { headers }), | ||
| 263 | ); | ||
| 264 | const executor = yield* makePlatformExecutor(organizationId).pipe( | ||
| 265 | Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), | ||
| 266 | ); | ||
| 267 | // The authorized tenant is handed to the body so an identity join reads the | ||
| 268 | // SAME org the reads are scoped to — never one named by client input. | ||
| 269 | return yield* Effect.ensuring( | ||
| 270 | body(executor, organizationId), | ||
| 271 | executor.close().pipe(Effect.ignore), | ||
| 272 | ); | ||
| 273 | }); | ||
| 274 | |||
| 275 | /** | ||
| 276 | * Cloud's `AdminUsersProvider`, built per request so the platform executor | ||
| 277 | * closes over the per-request postgres socket. | ||
| 278 | */ | ||
| 279 | export const workosAdminUsersProvider: Layer.Layer< | ||
| 280 | AdminUsersProvider, | ||
| 281 | never, | ||
| 282 | WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | ||
| 283 | > = Layer.effect(AdminUsersProvider)( | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const context = yield* Effect.context< | ||
| 286 | WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | ||
| 287 | >(); | ||
| 288 | return AdminUsersProvider.of({ | ||
| 289 | listUsers: (headers, options) => | ||
| 290 | withPlatformView(headers, (executor, organizationId) => | ||
| 291 | platformViewOf(executor).pipe( | ||
| 292 | Effect.flatMap((admin) => | ||
| 293 | listAdminUsers(admin, options, userDirectory(organizationId, context)), | ||
| 294 | ), | ||
| 295 | ), | ||
| 296 | ).pipe(Effect.provideContext(context)), | ||
| 297 | listUsersWithConnections: (headers, options) => | ||
| 298 | withPlatformView(headers, (executor, organizationId) => | ||
| 299 | platformViewOf(executor).pipe( | ||
| 300 | Effect.flatMap((admin) => | ||
| 301 | listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), | ||
| 302 | ), | ||
| 303 | ), | ||
| 304 | ).pipe(Effect.provideContext(context)), | ||
| 305 | listUserConnections: (headers, externalId) => | ||
| 306 | withPlatformView(headers, (executor) => | ||
| 307 | platformViewOf(executor).pipe( | ||
| 308 | Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), | ||
| 309 | ), | ||
| 310 | ).pipe(Effect.provideContext(context)), | ||
| 311 | getUser: (headers, identifier) => | ||
| 312 | withPlatformView(headers, (executor, organizationId) => | ||
| 313 | platformViewOf(executor).pipe( | ||
| 314 | Effect.flatMap((admin) => | ||
| 315 | getAdminUser(admin, identifier, userDirectory(organizationId, context)), | ||
| 316 | ), | ||
| 317 | ), | ||
| 318 | ).pipe(Effect.provideContext(context)), | ||
| 319 | }); | ||
| 320 | }), | ||
| 321 | ); | ||
| 322 | |||
| 323 | // Builds the provider per request, providing it to the handlers. Long-lived | ||
| 324 | // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the | ||
| 325 | // per-request `DbService`/`UserStoreService` (and the execution seams built | ||
| 326 | // over them) are supplied by the combined `requestScopedMiddleware`. | ||
| 327 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( | ||
| 328 | Effect.gen(function* () { | ||
| 329 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); | ||
| 330 | return (httpEffect) => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | // Built inside the request body so the execution seams close over the | ||
| 333 | // per-request postgres socket. | ||
| 334 | const provider = yield* Effect.provide( | ||
| 335 | AdminUsersProvider.asEffect(), | ||
| 336 | workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)), | ||
| 337 | ); | ||
| 338 | return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider); | ||
| 339 | }).pipe(Effect.provideContext(longLived)); | ||
| 340 | }), | ||
| 341 | ); | ||
| 342 | |||
| 343 | /** | ||
| 344 | * The cloud admin-users route layer, mounted as an app extension under the same | ||
| 345 | * `/api` prefix as the rest of the cloud router. | ||
| 346 | */ | ||
| 347 | export const makeCloudAdminUsersRoutes = ( | ||
| 348 | rsLive: Layer.Layer<DbService | UserStoreService>, | ||
| 349 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, | ||
| 350 | ) => | ||
| 351 | makeAdminUsersApiLayer( | ||
| 352 | AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer, | ||
| 353 | options, | ||
| 354 | ); | ||
| @@ -0,0 +1,127 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { Data, Effect, Layer } from "effect"; | ||
| 4 | |||
| 5 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | ||
| 6 | import { emailResolver } from "./admin-users-api"; | ||
| 7 | |||
| 8 | // Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the | ||
| 9 | // subject table records in `external_id`. Production resolves it with one | ||
| 10 | // tenant-scoped list-users query. The WorkOS emulator lacks that route, so | ||
| 11 | // tests/dev retain the membership-backed scan exercised below. | ||
| 12 | |||
| 13 | const ORG = "org_placeholder"; | ||
| 14 | const OTHER_ORG = "org_other"; | ||
| 15 | |||
| 16 | class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ | ||
| 17 | readonly userId: string; | ||
| 18 | }> {} | ||
| 19 | |||
| 20 | const DIRECTORY = [ | ||
| 21 | // Same email in another tenant must never win either lookup path. | ||
| 22 | { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG }, | ||
| 23 | // WorkOS preserves submitted casing, while the resolver seam is normalized. | ||
| 24 | { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG }, | ||
| 25 | { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG }, | ||
| 26 | { id: "user_nameless", email: null, organizationId: ORG }, | ||
| 27 | ] as const; | ||
| 28 | |||
| 29 | const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet<string>) => | ||
| 30 | Layer.succeed( | ||
| 31 | WorkOSClient, | ||
| 32 | new Proxy({} as WorkOSClientService, { | ||
| 33 | get: (_target, prop) => { | ||
| 34 | if (prop === "listUsers") { | ||
| 35 | return (params: { email: string; organizationId: string }) => { | ||
| 36 | calls.push(`listUsers:${params.organizationId}:${params.email}`); | ||
| 37 | return Effect.succeed({ | ||
| 38 | data: DIRECTORY.filter( | ||
| 39 | (user) => | ||
| 40 | user.organizationId === params.organizationId && | ||
| 41 | user.email?.toLowerCase() === params.email, | ||
| 42 | ), | ||
| 43 | }); | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | if (prop === "listOrgMembers") { | ||
| 47 | return (organizationId: string) => { | ||
| 48 | calls.push(`listOrgMembers:${organizationId}`); | ||
| 49 | return Effect.succeed({ | ||
| 50 | data: DIRECTORY.filter((user) => user.organizationId === organizationId).map( | ||
| 51 | (user) => ({ userId: user.id, organizationId }), | ||
| 52 | ), | ||
| 53 | }); | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | if (prop === "getUser") { | ||
| 57 | return (userId: string) => { | ||
| 58 | calls.push(`getUser:${userId}`); | ||
| 59 | if (unreadableUserIds.has(userId)) { | ||
| 60 | return Effect.fail(new WorkOSUnavailable({ userId })); | ||
| 61 | } | ||
| 62 | const user = DIRECTORY.find((candidate) => candidate.id === userId); | ||
| 63 | if (!user) return Effect.die(`unexpected user ${userId}`); | ||
| 64 | return Effect.succeed(user); | ||
| 65 | }; | ||
| 66 | } | ||
| 67 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 68 | }, | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | |||
| 72 | const resolve = ( | ||
| 73 | email: string, | ||
| 74 | calls: string[], | ||
| 75 | emulator = false, | ||
| 76 | unreadableUserIds: ReadonlySet<string> = new Set(), | ||
| 77 | ) => { | ||
| 78 | const previousApiUrl = env.WORKOS_API_URL; | ||
| 79 | return Effect.gen(function* () { | ||
| 80 | yield* Effect.sync(() => | ||
| 81 | Object.assign(env, { | ||
| 82 | WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined, | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | const context = yield* Effect.context<WorkOSClient>(); | ||
| 86 | return yield* emailResolver(ORG, context)(email); | ||
| 87 | }).pipe( | ||
| 88 | Effect.provide(stubWorkOS(calls, unreadableUserIds)), | ||
| 89 | Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))), | ||
| 90 | ); | ||
| 91 | }; | ||
| 92 | |||
| 93 | it.effect("resolves an email with one tenant-scoped WorkOS query", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const calls: string[] = []; | ||
| 96 | expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); | ||
| 97 | expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("returns null from one query when the organization has no matching email", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const calls: string[] = []; | ||
| 104 | expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); | ||
| 105 | expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]); | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 109 | it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () => | ||
| 110 | Effect.gen(function* () { | ||
| 111 | const calls: string[] = []; | ||
| 112 | expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada"); | ||
| 113 | expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]); | ||
| 114 | expect(calls).not.toContain("getUser:user_foreign"); | ||
| 115 | expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false); | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | |||
| 119 | it.effect("lets the emulator fallback continue past one unreadable member", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const calls: string[] = []; | ||
| 122 | expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe( | ||
| 123 | "user_grace", | ||
| 124 | ); | ||
| 125 | expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]); | ||
| 126 | }), | ||
| 127 | ); | ||
| @@ -0,0 +1,394 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Cloud's PostHog-backed enterprise-managed rollout gate. | ||
| 3 | // | ||
| 4 | // The gate is exercised through its production seam: the real | ||
| 5 | // `makePostHogEnterpriseManagedRollout` with an injected `fetch` and an | ||
| 6 | // injected `waitUntil`, exactly as `auth/jwks-cache.node.test.ts` drives the | ||
| 7 | // JWKS client. Nothing is module-mocked, so the request these tests read is the | ||
| 8 | // request PostHog would receive. | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | import { describe, expect, it } from "@effect/vitest"; | ||
| 12 | import { Effect } from "effect"; | ||
| 13 | |||
| 14 | import type { EnterpriseManagedRolloutContext } from "@executor-js/sdk"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | ENTERPRISE_MANAGED_AUTH_FLAG_KEY, | ||
| 18 | cloudEnterpriseManagedRollout, | ||
| 19 | makePostHogEnterpriseManagedRollout, | ||
| 20 | } from "./ema-rollout"; | ||
| 21 | |||
| 22 | const HOST = "https://us.i.posthog.com"; | ||
| 23 | const PROJECT_KEY = "phc_test_project_key"; | ||
| 24 | |||
| 25 | const CONTEXT: EnterpriseManagedRolloutContext = { | ||
| 26 | userId: "user_01ABC", | ||
| 27 | organizationId: "org_01XYZ", | ||
| 28 | integration: "slack" as EnterpriseManagedRolloutContext["integration"], | ||
| 29 | }; | ||
| 30 | |||
| 31 | interface CapturedRequest { | ||
| 32 | readonly url: string; | ||
| 33 | readonly body: Record<string, unknown>; | ||
| 34 | /** The bound the gate put on this call, when it set one. */ | ||
| 35 | readonly signal: AbortSignal | null; | ||
| 36 | } | ||
| 37 | |||
| 38 | interface FetchHarness { | ||
| 39 | readonly fetch: typeof globalThis.fetch; | ||
| 40 | readonly requests: () => readonly CapturedRequest[]; | ||
| 41 | /** Resolve every request the gate detached, so an assertion cannot race it. */ | ||
| 42 | readonly settle: () => Promise<void>; | ||
| 43 | readonly waitUntil: (work: Promise<unknown>) => void; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** | ||
| 47 | * Stands in for PostHog. `respond` decides what each call returns; throwing | ||
| 48 | * from it models a transport failure, and an aborted signal models the timeout. | ||
| 49 | */ | ||
| 50 | const makeFetchHarness = ( | ||
| 51 | respond: (request: CapturedRequest) => Response | Promise<Response>, | ||
| 52 | ): FetchHarness => { | ||
| 53 | const requests: CapturedRequest[] = []; | ||
| 54 | const detached: Promise<unknown>[] = []; | ||
| 55 | return { | ||
| 56 | requests: () => requests, | ||
| 57 | settle: async () => { | ||
| 58 | await Promise.all(detached); | ||
| 59 | }, | ||
| 60 | waitUntil: (work) => { | ||
| 61 | detached.push(work); | ||
| 62 | }, | ||
| 63 | fetch: async (input, init) => { | ||
| 64 | const raw = typeof init?.body === "string" ? init.body : "{}"; | ||
| 65 | const captured: CapturedRequest = { | ||
| 66 | url: String(input), | ||
| 67 | // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture reads back the exact JSON body the gate serialized for PostHog | ||
| 68 | body: JSON.parse(raw) as Record<string, unknown>, | ||
| 69 | signal: init?.signal instanceof AbortSignal ? init.signal : null, | ||
| 70 | }; | ||
| 71 | requests.push(captured); | ||
| 72 | return respond(captured); | ||
| 73 | }, | ||
| 74 | }; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const jsonResponse = (body: unknown, status = 200): Response => | ||
| 78 | new Response(JSON.stringify(body), { | ||
| 79 | status, | ||
| 80 | headers: { "content-type": "application/json" }, | ||
| 81 | }); | ||
| 82 | |||
| 83 | const flagsBody = (enabled: boolean) => ({ | ||
| 84 | flags: { | ||
| 85 | [ENTERPRISE_MANAGED_AUTH_FLAG_KEY]: { | ||
| 86 | key: ENTERPRISE_MANAGED_AUTH_FLAG_KEY, | ||
| 87 | enabled, | ||
| 88 | reason: { code: "condition_match" }, | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | errorsWhileComputingFlags: false, | ||
| 92 | }); | ||
| 93 | |||
| 94 | const gate = (harness: FetchHarness, timeoutMs?: number) => | ||
| 95 | makePostHogEnterpriseManagedRollout({ | ||
| 96 | projectKey: PROJECT_KEY, | ||
| 97 | host: HOST, | ||
| 98 | fetch: harness.fetch, | ||
| 99 | waitUntil: harness.waitUntil, | ||
| 100 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | ||
| 101 | }); | ||
| 102 | |||
| 103 | describe("flag evaluation", () => { | ||
| 104 | it.effect("keys the rollout on the user and carries the org as group context", () => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); | ||
| 107 | |||
| 108 | const decision = yield* gate(harness).decide(CONTEXT); | ||
| 109 | |||
| 110 | expect(decision).toEqual({ kind: "enabled" }); | ||
| 111 | const [request] = harness.requests(); | ||
| 112 | expect(request?.url).toBe(`${HOST}/flags?v=2`); | ||
| 113 | expect( | ||
| 114 | request?.body.distinct_id, | ||
| 115 | "the rollout unit is the user, matching posthog.identify(user.id) in the browser", | ||
| 116 | ).toBe(CONTEXT.userId); | ||
| 117 | expect( | ||
| 118 | request?.body.groups, | ||
| 119 | "the org rides along so group targeting stays available without changing the rollout unit", | ||
| 120 | ).toEqual({ organization: CONTEXT.organizationId }); | ||
| 121 | expect(request?.body.api_key).toBe(PROJECT_KEY); | ||
| 122 | expect(request?.signal, "the evaluation is always bounded").not.toBeNull(); | ||
| 123 | }), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect("omits group context when the host named no organization", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); | ||
| 129 | |||
| 130 | yield* gate(harness).decide({ ...CONTEXT, organizationId: null }); | ||
| 131 | |||
| 132 | expect(harness.requests()[0]?.body.groups).toBeUndefined(); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("withholds when the flag is off for this user", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const harness = makeFetchHarness(() => jsonResponse(flagsBody(false))); | ||
| 139 | |||
| 140 | expect(yield* gate(harness).decide(CONTEXT)).toEqual({ | ||
| 141 | kind: "withheld", | ||
| 142 | reason: "disabled", | ||
| 143 | }); | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | it.effect("withholds when PostHog returned no verdict for the flag at all", () => | ||
| 148 | Effect.gen(function* () { | ||
| 149 | const harness = makeFetchHarness(() => | ||
| 150 | // What a quota-limited project answers. | ||
| 151 | jsonResponse({ | ||
| 152 | flags: {}, | ||
| 153 | errorsWhileComputingFlags: false, | ||
| 154 | quotaLimited: ["feature_flags"], | ||
| 155 | }), | ||
| 156 | ); | ||
| 157 | |||
| 158 | expect(yield* gate(harness).decide(CONTEXT)).toEqual({ | ||
| 159 | kind: "withheld", | ||
| 160 | reason: "disabled", | ||
| 161 | }); | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | }); | ||
| 165 | |||
| 166 | describe("failing closed", () => { | ||
| 167 | it.effect("withholds on a non-2xx answer", () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const harness = makeFetchHarness(() => new Response("nope", { status: 503 })); | ||
| 170 | |||
| 171 | expect(yield* gate(harness).decide(CONTEXT)).toEqual({ | ||
| 172 | kind: "withheld", | ||
| 173 | reason: "evaluation-unavailable", | ||
| 174 | }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("withholds on a transport failure", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const harness = makeFetchHarness(() => { | ||
| 181 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models `fetch` rejecting with a TypeError, which is exactly how a Worker sees a dead upstream | ||
| 182 | throw new TypeError("network error"); | ||
| 183 | }); | ||
| 184 | |||
| 185 | expect(yield* gate(harness).decide(CONTEXT)).toEqual({ | ||
| 186 | kind: "withheld", | ||
| 187 | reason: "evaluation-unavailable", | ||
| 188 | }); | ||
| 189 | }), | ||
| 190 | ); | ||
| 191 | |||
| 192 | it.effect("withholds when the evaluation times out", () => | ||
| 193 | Effect.gen(function* () { | ||
| 194 | // Never answers. Only the gate's own AbortSignal ends this call, so the | ||
| 195 | // test fails by hanging if the gate ever stops bounding the evaluation. | ||
| 196 | const harness = makeFetchHarness( | ||
| 197 | (request) => | ||
| 198 | new Promise<Response>((_resolve, reject) => { | ||
| 199 | request.signal?.addEventListener("abort", () => | ||
| 200 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch-compatible fixture mirrors the platform's abort rejection semantics | ||
| 201 | reject(new DOMException("aborted", "AbortError")), | ||
| 202 | ); | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | |||
| 206 | expect( | ||
| 207 | yield* gate(harness, 1).decide(CONTEXT), | ||
| 208 | "a slow flag service must degrade the rollout, never hold up a connect", | ||
| 209 | ).toEqual({ kind: "withheld", reason: "evaluation-unavailable" }); | ||
| 210 | }), | ||
| 211 | ); | ||
| 212 | |||
| 213 | it.effect("withholds on a body that is not a flags response", () => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const harness = makeFetchHarness(() => jsonResponse({ flags: "not-an-object" })); | ||
| 216 | |||
| 217 | expect(yield* gate(harness).decide(CONTEXT)).toEqual({ | ||
| 218 | kind: "withheld", | ||
| 219 | reason: "evaluation-unavailable", | ||
| 220 | }); | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | |||
| 224 | it.effect("withholds when there is no acting user to roll out by", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); | ||
| 227 | |||
| 228 | expect(yield* gate(harness).decide({ ...CONTEXT, userId: null })).toEqual({ | ||
| 229 | kind: "withheld", | ||
| 230 | reason: "evaluation-unavailable", | ||
| 231 | }); | ||
| 232 | expect(harness.requests().length, "and asks PostHog nothing").toBe(0); | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | |||
| 236 | it.effect("withholds when the deployment has no PostHog configuration", () => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | // The cloud test env carries no VITE_PUBLIC_POSTHOG_KEY, so this builds | ||
| 239 | // the misconfigured-deployment gate — which is deliberately NOT the same | ||
| 240 | // as a host that injects no gate at all. | ||
| 241 | expect(yield* cloudEnterpriseManagedRollout().decide(CONTEXT)).toEqual({ | ||
| 242 | kind: "withheld", | ||
| 243 | reason: "evaluation-unavailable", | ||
| 244 | }); | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | }); | ||
| 248 | |||
| 249 | describe("rollout events", () => { | ||
| 250 | it.effect("captures the connect outcome with the flag decision attached", () => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); | ||
| 253 | const rollout = gate(harness); | ||
| 254 | |||
| 255 | yield* rollout.record({ | ||
| 256 | kind: "connected", | ||
| 257 | context: CONTEXT, | ||
| 258 | decision: { kind: "enabled" }, | ||
| 259 | }); | ||
| 260 | yield* Effect.promise(() => harness.settle()); | ||
| 261 | |||
| 262 | const [request] = harness.requests(); | ||
| 263 | expect(request?.url).toBe(`${HOST}/i/v0/e/`); | ||
| 264 | expect(request?.body.event).toBe("ema_connect_connected"); | ||
| 265 | expect(request?.body.distinct_id).toBe(CONTEXT.userId); | ||
| 266 | const properties = request?.body.properties as Record<string, unknown>; | ||
| 267 | expect(properties.ema_flag_enabled).toBe(true); | ||
| 268 | expect(properties[`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]).toBe(true); | ||
| 269 | expect(properties.$groups).toEqual({ | ||
| 270 | organization: CONTEXT.organizationId, | ||
| 271 | }); | ||
| 272 | expect(properties.integration_slug).toBe("slack"); | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | |||
| 276 | it.effect("carries the withheld reason on the attempt that never ran", () => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); | ||
| 279 | |||
| 280 | yield* gate(harness).record({ | ||
| 281 | kind: "attempted", | ||
| 282 | context: CONTEXT, | ||
| 283 | decision: { kind: "withheld", reason: "evaluation-unavailable" }, | ||
| 284 | }); | ||
| 285 | yield* Effect.promise(() => harness.settle()); | ||
| 286 | |||
| 287 | const properties = harness.requests()[0]?.body.properties as Record<string, unknown>; | ||
| 288 | expect(harness.requests()[0]?.body.event).toBe("ema_connect_attempted"); | ||
| 289 | expect(properties.ema_flag_enabled).toBe(false); | ||
| 290 | expect(properties.ema_flag_withheld_reason).toBe("evaluation-unavailable"); | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | |||
| 294 | it.effect("carries the identity provider's own error code on a blocked connect", () => | ||
| 295 | Effect.gen(function* () { | ||
| 296 | const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); | ||
| 297 | |||
| 298 | yield* gate(harness).record({ | ||
| 299 | kind: "blocked-by-admin", | ||
| 300 | context: CONTEXT, | ||
| 301 | decision: { kind: "enabled" }, | ||
| 302 | oauthErrorCode: "unauthorized_client", | ||
| 303 | }); | ||
| 304 | yield* Effect.promise(() => harness.settle()); | ||
| 305 | |||
| 306 | const [request] = harness.requests(); | ||
| 307 | expect(request?.body.event).toBe("ema_connect_blocked_by_admin"); | ||
| 308 | const properties = request?.body.properties as Record<string, unknown> | undefined; | ||
| 309 | expect(properties?.oauth_error_code).toBe("unauthorized_client"); | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | |||
| 313 | it.effect("sends nothing beyond identity, the integration and the flag decision", () => | ||
| 314 | Effect.gen(function* () { | ||
| 315 | const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); | ||
| 316 | |||
| 317 | yield* gate(harness).record({ | ||
| 318 | kind: "blocked-by-admin", | ||
| 319 | context: CONTEXT, | ||
| 320 | decision: { kind: "enabled" }, | ||
| 321 | oauthErrorCode: "access_denied", | ||
| 322 | }); | ||
| 323 | yield* Effect.promise(() => harness.settle()); | ||
| 324 | |||
| 325 | const properties = harness.requests()[0]?.body.properties as Record<string, unknown>; | ||
| 326 | expect( | ||
| 327 | Object.keys(properties).sort(), | ||
| 328 | "a closed property set is what keeps a token or an assertion from ever being added by accident", | ||
| 329 | ).toEqual( | ||
| 330 | [ | ||
| 331 | "$feature/mcp-enterprise-managed-auth", | ||
| 332 | "$groups", | ||
| 333 | "ema_flag_enabled", | ||
| 334 | "integration_slug", | ||
| 335 | "oauth_error_code", | ||
| 336 | ].sort(), | ||
| 337 | ); | ||
| 338 | }), | ||
| 339 | ); | ||
| 340 | |||
| 341 | it.effect("hands the capture to the platform instead of awaiting it", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | let resolveCapture: (() => void) | undefined; | ||
| 344 | const harness = makeFetchHarness( | ||
| 345 | () => | ||
| 346 | new Promise<Response>((resolve) => { | ||
| 347 | resolveCapture = () => resolve(jsonResponse({ status: 1 })); | ||
| 348 | }), | ||
| 349 | ); | ||
| 350 | |||
| 351 | // Returns while the capture is still in flight: an analytics call can | ||
| 352 | // never be on the critical path of a connect. | ||
| 353 | yield* gate(harness).record({ | ||
| 354 | kind: "attempted", | ||
| 355 | context: CONTEXT, | ||
| 356 | decision: { kind: "enabled" }, | ||
| 357 | }); | ||
| 358 | |||
| 359 | expect(harness.requests().length).toBe(1); | ||
| 360 | resolveCapture?.(); | ||
| 361 | yield* Effect.promise(() => harness.settle()); | ||
| 362 | }), | ||
| 363 | ); | ||
| 364 | |||
| 365 | it.effect("survives a capture that fails outright", () => | ||
| 366 | Effect.gen(function* () { | ||
| 367 | const harness = makeFetchHarness(() => { | ||
| 368 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models the ingest endpoint being unreachable | ||
| 369 | throw new TypeError("network error"); | ||
| 370 | }); | ||
| 371 | |||
| 372 | yield* gate(harness).record({ | ||
| 373 | kind: "connected", | ||
| 374 | context: CONTEXT, | ||
| 375 | decision: { kind: "enabled" }, | ||
| 376 | }); | ||
| 377 | yield* Effect.promise(() => harness.settle()); | ||
| 378 | }), | ||
| 379 | ); | ||
| 380 | |||
| 381 | it.effect("records nothing when there is no person to attach the event to", () => | ||
| 382 | Effect.gen(function* () { | ||
| 383 | const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); | ||
| 384 | |||
| 385 | yield* gate(harness).record({ | ||
| 386 | kind: "attempted", | ||
| 387 | context: { ...CONTEXT, userId: null }, | ||
| 388 | decision: { kind: "withheld", reason: "evaluation-unavailable" }, | ||
| 389 | }); | ||
| 390 | |||
| 391 | expect(harness.requests().length).toBe(0); | ||
| 392 | }), | ||
| 393 | ); | ||
| 394 | }); | ||
| @@ -0,0 +1,255 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Cloud's rollout gate for MCP Enterprise-Managed Authorization. | ||
| 3 | // | ||
| 4 | // Implements the vendor-free `EnterpriseManagedRollout` port from | ||
| 5 | // `@executor-js/sdk` against PostHog, which is the only flag/analytics service | ||
| 6 | // this deployment operates. Two deliberate shapes: | ||
| 7 | // | ||
| 8 | // 1. NO SDK. This is a hand-written `fetch` against two documented PostHog | ||
| 9 | // endpoints, not `posthog-node`. Cloud already ate an unexplained 3-5s | ||
| 10 | // page-load regression from adding one dependency to the worker bundle and | ||
| 11 | // the mechanism was never identified (see the notes on the MCP SDK v2 | ||
| 12 | // revert). Until that is understood, a new runtime dependency in this bundle | ||
| 13 | // is a cost we are not willing to pay for a boolean, and the whole client we | ||
| 14 | // would be importing is two POSTs wide. | ||
| 15 | // | ||
| 16 | // 2. FAIL CLOSED, ALWAYS. Every way of not getting an answer — timeout, | ||
| 17 | // transport failure, non-2xx, a body we cannot parse, no PostHog key | ||
| 18 | // configured, no acting user to key the rollout on — withholds the | ||
| 19 | // enterprise-managed path and falls back to ordinary interactive OAuth. A | ||
| 20 | // PostHog outage therefore degrades the rollout and never fails a connect, | ||
| 21 | // and (because the SDK freezes the verdict onto the connection) never | ||
| 22 | // touches an enterprise-managed connection that already exists. | ||
| 23 | // | ||
| 24 | // The rollout UNIT is the user: `distinct_id` is the acting user's id, the same | ||
| 25 | // id `posthog.identify(user.id, …)` uses in the browser, so a percentage | ||
| 26 | // rollout here means the same population it means everywhere else in the | ||
| 27 | // project. The organization rides along as group context so org-level targeting | ||
| 28 | // stays available in the PostHog UI without changing that unit. | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | import { env } from "cloudflare:workers"; | ||
| 32 | import { Effect, Schema } from "effect"; | ||
| 33 | |||
| 34 | import type { | ||
| 35 | EnterpriseManagedRollout, | ||
| 36 | EnterpriseManagedRolloutContext, | ||
| 37 | EnterpriseManagedRolloutDecision, | ||
| 38 | EnterpriseManagedRolloutEvent, | ||
| 39 | } from "@executor-js/sdk"; | ||
| 40 | |||
| 41 | import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; | ||
| 42 | |||
| 43 | /** The flag that gates enterprise-managed authorization. */ | ||
| 44 | export const ENTERPRISE_MANAGED_AUTH_FLAG_KEY = "mcp-enterprise-managed-auth"; | ||
| 45 | |||
| 46 | /** PostHog group type for organizations — the same one | ||
| 47 | * `posthog.group("organization", …)` establishes in the browser. */ | ||
| 48 | const ORGANIZATION_GROUP_TYPE = "organization"; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * How long a flag evaluation may take before the gate gives up and withholds. | ||
| 52 | * Short on purpose: this call sits in front of an interactive connect, and the | ||
| 53 | * safe answer is already known, so waiting is strictly worse than answering. | ||
| 54 | */ | ||
| 55 | export const DEFAULT_FLAG_EVALUATION_TIMEOUT_MS = 1_000; | ||
| 56 | |||
| 57 | /** Wire names of the rollout events, in the product's `object_verb` style. */ | ||
| 58 | const EVENT_NAMES = { | ||
| 59 | attempted: "ema_connect_attempted", | ||
| 60 | connected: "ema_connect_connected", | ||
| 61 | "blocked-by-admin": "ema_connect_blocked_by_admin", | ||
| 62 | } as const satisfies Record<EnterpriseManagedRolloutEvent["kind"], string>; | ||
| 63 | |||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | // Boundary parsing | ||
| 66 | // --------------------------------------------------------------------------- | ||
| 67 | |||
| 68 | /** The slice of PostHog's `POST /flags?v=2` response this gate reads. Unknown | ||
| 69 | * keys are ignored, so PostHog adding fields cannot make the gate fail closed; | ||
| 70 | * a body that is not this shape at all can, which is the intent. */ | ||
| 71 | const FlagsResponse = Schema.Struct({ | ||
| 72 | flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })), | ||
| 73 | }); | ||
| 74 | |||
| 75 | const decodeFlagsResponse = Schema.decodeUnknownEffect(FlagsResponse); | ||
| 76 | |||
| 77 | // --------------------------------------------------------------------------- | ||
| 78 | // Detached work | ||
| 79 | // --------------------------------------------------------------------------- | ||
| 80 | |||
| 81 | /** | ||
| 82 | * Hand a request to the platform and stop caring about it. | ||
| 83 | * | ||
| 84 | * `waitUntil` is the correct owner when the caller has an `ExecutionContext`; | ||
| 85 | * the executor composition seam this gate is installed through does not receive | ||
| 86 | * one, so the default owner attaches a terminal rejection handler and lets the | ||
| 87 | * in-flight request ride out the rest of the response. Either way the promise | ||
| 88 | * is owned by something that cannot report back, which is what makes an event | ||
| 89 | * structurally unable to fail or delay a connect. | ||
| 90 | */ | ||
| 91 | const detach = ( | ||
| 92 | work: Promise<unknown>, | ||
| 93 | waitUntil: ((work: Promise<unknown>) => void) | undefined, | ||
| 94 | ): void => { | ||
| 95 | const settled = work.then( | ||
| 96 | () => undefined, | ||
| 97 | () => undefined, | ||
| 98 | ); | ||
| 99 | if (waitUntil) waitUntil(settled); | ||
| 100 | }; | ||
| 101 | |||
| 102 | // --------------------------------------------------------------------------- | ||
| 103 | // The gate | ||
| 104 | // --------------------------------------------------------------------------- | ||
| 105 | |||
| 106 | export interface PostHogRolloutConfig { | ||
| 107 | /** Public project key (`phc_…`). Public by design — it is already shipped to | ||
| 108 | * every browser — so this is a var, not a secret. */ | ||
| 109 | readonly projectKey: string; | ||
| 110 | /** PostHog API origin (no trailing slash), e.g. `https://us.i.posthog.com`. */ | ||
| 111 | readonly host: string; | ||
| 112 | readonly fetch: typeof globalThis.fetch; | ||
| 113 | readonly timeoutMs?: number; | ||
| 114 | /** Platform post-response hook, when the caller has one. */ | ||
| 115 | readonly waitUntil?: (work: Promise<unknown>) => void; | ||
| 116 | } | ||
| 117 | |||
| 118 | const withheld = ( | ||
| 119 | reason: "disabled" | "evaluation-unavailable", | ||
| 120 | ): EnterpriseManagedRolloutDecision => ({ kind: "withheld", reason }); | ||
| 121 | |||
| 122 | const ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled" }; | ||
| 123 | |||
| 124 | /** Group context for a connect whose organization the host named. */ | ||
| 125 | const groupsFor = (context: EnterpriseManagedRolloutContext): Record<string, string> | undefined => | ||
| 126 | context.organizationId === null | ||
| 127 | ? undefined | ||
| 128 | : { [ORGANIZATION_GROUP_TYPE]: context.organizationId }; | ||
| 129 | |||
| 130 | /** | ||
| 131 | * Build cloud's enterprise-managed rollout gate over an explicit PostHog | ||
| 132 | * configuration. Everything environmental is a parameter so the composition | ||
| 133 | * root parses it once and tests drive the real code path with an injected | ||
| 134 | * `fetch`. | ||
| 135 | */ | ||
| 136 | export const makePostHogEnterpriseManagedRollout = ( | ||
| 137 | config: PostHogRolloutConfig, | ||
| 138 | ): EnterpriseManagedRollout => { | ||
| 139 | const timeoutMs = config.timeoutMs ?? DEFAULT_FLAG_EVALUATION_TIMEOUT_MS; | ||
| 140 | |||
| 141 | const evaluate = Effect.fn("Cloud.EnterpriseManagedRollout.decide")(function* ( | ||
| 142 | context: EnterpriseManagedRolloutContext, | ||
| 143 | ) { | ||
| 144 | // The rollout unit is the user. With no acting user there is no | ||
| 145 | // `distinct_id` to key it on, so there is no answer to be had — withhold | ||
| 146 | // rather than invent a rollout bucket. | ||
| 147 | const distinctId = context.userId; | ||
| 148 | if (distinctId === null) return withheld("evaluation-unavailable"); | ||
| 149 | |||
| 150 | const groups = groupsFor(context); | ||
| 151 | const response = yield* Effect.tryPromise(() => | ||
| 152 | config.fetch(`${config.host}/flags?v=2`, { | ||
| 153 | method: "POST", | ||
| 154 | headers: { | ||
| 155 | "content-type": "application/json", | ||
| 156 | // Declares this a server-side evaluation, which is what selects | ||
| 157 | // server-runtime flags in PostHog's runtime detection. | ||
| 158 | "user-agent": "executor-cloud", | ||
| 159 | }, | ||
| 160 | body: JSON.stringify({ | ||
| 161 | api_key: config.projectKey, | ||
| 162 | distinct_id: distinctId, | ||
| 163 | ...(groups === undefined ? {} : { groups }), | ||
| 164 | }), | ||
| 165 | signal: AbortSignal.timeout(timeoutMs), | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | if (!response.ok) return withheld("evaluation-unavailable"); | ||
| 169 | |||
| 170 | const body = yield* Effect.tryPromise(() => response.json() as Promise<unknown>); | ||
| 171 | const decoded = yield* decodeFlagsResponse(body); | ||
| 172 | const flag = decoded.flags[ENTERPRISE_MANAGED_AUTH_FLAG_KEY]; | ||
| 173 | // A flag PostHog did not return is a flag that is not on for this user | ||
| 174 | // (it may not exist yet, or the project may be quota-limited). That is a | ||
| 175 | // real "no", not a failure to answer. | ||
| 176 | if (flag === undefined || !flag.enabled) return withheld("disabled"); | ||
| 177 | return ENABLED; | ||
| 178 | }); | ||
| 179 | |||
| 180 | return { | ||
| 181 | decide: (context) => | ||
| 182 | evaluate(context).pipe( | ||
| 183 | // Timeout, transport failure and an unparseable body all land here and | ||
| 184 | // all mean the same thing: no answer, so no enterprise-managed attempt. | ||
| 185 | // `catchCause` rather than `catchAll` because a defect in this adapter | ||
| 186 | // must not become a failed connect either. | ||
| 187 | Effect.catchCause(() => Effect.succeed(withheld("evaluation-unavailable"))), | ||
| 188 | ), | ||
| 189 | record: (event) => | ||
| 190 | Effect.sync(() => { | ||
| 191 | const distinctId = event.context.userId; | ||
| 192 | // Same reason `decide` withholds: an event with no person to attach to | ||
| 193 | // would only pollute the project with anonymous rows. | ||
| 194 | if (distinctId === null) return; | ||
| 195 | const groups = groupsFor(event.context); | ||
| 196 | const decision = event.decision; | ||
| 197 | detach( | ||
| 198 | config.fetch(`${config.host}/i/v0/e/`, { | ||
| 199 | method: "POST", | ||
| 200 | headers: { "content-type": "application/json" }, | ||
| 201 | body: JSON.stringify({ | ||
| 202 | api_key: config.projectKey, | ||
| 203 | event: EVENT_NAMES[event.kind], | ||
| 204 | distinct_id: distinctId, | ||
| 205 | properties: { | ||
| 206 | // Product metadata only. There is no field on | ||
| 207 | // `EnterpriseManagedRolloutEvent` that can carry a token, an | ||
| 208 | // identity assertion, a client secret or a scope, and none is | ||
| 209 | // synthesized here. | ||
| 210 | integration_slug: String(event.context.integration), | ||
| 211 | ema_flag_enabled: decision.kind === "enabled", | ||
| 212 | ...(decision.kind === "withheld" | ||
| 213 | ? { ema_flag_withheld_reason: decision.reason } | ||
| 214 | : {}), | ||
| 215 | // PostHog's own convention, so these events can be filtered by | ||
| 216 | // flag value in the UI alongside every other flag. | ||
| 217 | [`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]: decision.kind === "enabled", | ||
| 218 | ...(event.kind === "blocked-by-admin" && event.oauthErrorCode !== undefined | ||
| 219 | ? { oauth_error_code: event.oauthErrorCode } | ||
| 220 | : {}), | ||
| 221 | ...(groups === undefined ? {} : { $groups: groups }), | ||
| 222 | }, | ||
| 223 | }), | ||
| 224 | }), | ||
| 225 | config.waitUntil, | ||
| 226 | ); | ||
| 227 | }), | ||
| 228 | }; | ||
| 229 | }; | ||
| 230 | |||
| 231 | /** A gate that answers "no" to everything, for a deployment that has no PostHog | ||
| 232 | * configuration to evaluate against. Cloud is SUPPOSED to have one, so a | ||
| 233 | * missing key is a misconfiguration, and failing closed is the same choice the | ||
| 234 | * outage paths make. This is NOT the same as injecting no gate at all, which | ||
| 235 | * is how the hosts with no flag service (local, desktop, CLI, self-host) keep | ||
| 236 | * their existing behavior. */ | ||
| 237 | export const unavailableEnterpriseManagedRollout: EnterpriseManagedRollout = { | ||
| 238 | decide: () => Effect.succeed(withheld("evaluation-unavailable")), | ||
| 239 | record: () => Effect.void, | ||
| 240 | }; | ||
| 241 | |||
| 242 | /** | ||
| 243 | * The gate as the cloud worker composes it: the public project key already | ||
| 244 | * shipped in `wrangler.jsonc`, and the same PostHog ingest origin the | ||
| 245 | * adblock-dodging passthrough proxies to. | ||
| 246 | */ | ||
| 247 | export const cloudEnterpriseManagedRollout = (): EnterpriseManagedRollout => { | ||
| 248 | const projectKey = env.VITE_PUBLIC_POSTHOG_KEY; | ||
| 249 | if (!projectKey) return unavailableEnterpriseManagedRollout; | ||
| 250 | return makePostHogEnterpriseManagedRollout({ | ||
| 251 | projectKey, | ||
| 252 | host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`, | ||
| 253 | fetch: (input, init) => globalThis.fetch(input, init), | ||
| 254 | }); | ||
| 255 | }; | ||
| @@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines | |||
| 13 | Effect.succeed( | 13 | Effect.succeed( |
| 14 | value === "valid_user_key" | 14 | value === "valid_user_key" |
| 15 | ? { | 15 | ? { |
| 16 | scope: "user" as const, | ||
| 16 | accountId: "user_123", | 17 | accountId: "user_123", |
| 17 | organizationId: "org_123", | 18 | organizationId: "org_123", |
| 18 | keyId: "api_key_123", | 19 | keyId: "api_key_123", |
| @@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines | |||
| 22 | listUserKeys: () => Effect.succeed([]), | 23 | listUserKeys: () => Effect.succeed([]), |
| 23 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), | 24 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), |
| 24 | revokeUserKey: () => Effect.void, | 25 | revokeUserKey: () => Effect.void, |
| 26 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 27 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 28 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 25 | }); | 29 | }); |
| 26 | 30 | ||
| 27 | const stubWorkOS = Layer.succeed( | 31 | const stubWorkOS = Layer.succeed( |
| @@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | |||
| 43 | ); | 47 | ); |
| 44 | 48 | ||
| 45 | const stubUsers = Layer.succeed(UserStoreService)({ | 49 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 46 | use: (fn) => | 50 | use: (_op, fn) => |
| 47 | Effect.promise(() => | 51 | Effect.promise(() => |
| 48 | fn({ | 52 | fn({ |
| 49 | ensureAccount: async (id: string) => ({ id, createdAt }), | 53 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -85,6 +89,7 @@ describe("protected API key auth", () => { · 35 unmodified lines | |||
| 85 | ); | 89 | ); |
| 86 | 90 | ||
| 87 | expect(identity).toEqual({ | 91 | expect(identity).toEqual({ |
| 92 | kind: "member", | ||
| 88 | accountId: "user_123", | 93 | accountId: "user_123", |
| 89 | organizationId: "org_123", | 94 | organizationId: "org_123", |
| 90 | organizationName: "Org org_123", | 95 | organizationName: "Org org_123", |
| @@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines | |||
| 40 | listUserKeys: () => Effect.succeed([]), | 40 | listUserKeys: () => Effect.succeed([]), |
| 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), | 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), |
| 42 | revokeUserKey: () => Effect.void, | 42 | revokeUserKey: () => Effect.void, |
| 43 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 44 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 45 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 43 | }); | 46 | }); |
| 44 | 47 | ||
| 45 | const stubWorkOS = Layer.succeed( | 48 | const stubWorkOS = Layer.succeed( |
| @@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | |||
| 61 | ); | 64 | ); |
| 62 | 65 | ||
| 63 | const stubUsers = Layer.succeed(UserStoreService)({ | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 64 | use: (fn) => | 67 | use: (_op, fn) => |
| 65 | Effect.promise(() => | 68 | Effect.promise(() => |
| 66 | fn({ | 69 | fn({ |
| 67 | ensureAccount: async (id: string) => ({ id, createdAt }), | 70 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -107,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 39 unmodified lines | |||
| 107 | const identity = yield* run(request(token), config); | 110 | const identity = yield* run(request(token), config); |
| 108 | 111 | ||
| 109 | expect(identity).toEqual({ | 112 | expect(identity).toEqual({ |
| 113 | kind: "member", | ||
| 110 | accountId: "user_123", | 114 | accountId: "user_123", |
| 111 | organizationId: "org_123", | 115 | organizationId: "org_123", |
| 112 | organizationName: "Org org_123", | 116 | organizationName: "Org org_123", |
| @@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines | |||
| 20 | pausedExecutionCount: () => Effect.succeed(0), | 20 | pausedExecutionCount: () => Effect.succeed(0), |
| 21 | hasPausedExecutions: () => Effect.succeed(false), | 21 | hasPausedExecutions: () => Effect.succeed(false), |
| 22 | getDescription: Effect.succeed("desc"), | 22 | getDescription: Effect.succeed("desc"), |
| 23 | // The fake forks nothing, so there is no sandbox fiber to end. | ||
| 24 | shutdown: Effect.void, | ||
| 23 | }) as ExecutionEngine; | 25 | }) as ExecutionEngine; |
| 24 | 26 | ||
| 25 | describe("withExecutionUsageTracking", () => { | 27 | describe("withExecutionUsageTracking", () => { |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { isAppOwnedPath } from "./app-paths"; | 3 | import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; |
| 4 | 4 | ||
| 5 | // Guards the start.ts dispatch decision: every surface the unified app handler | 5 | // Guards the start.ts dispatch decision: every surface the unified app handler |
| 6 | // serves must be classified app-owned (forwarded to `app.handler`), and Start's | 6 | // serves must be classified app-owned (forwarded to `app.handler`), and Start's |
| @@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { · 55 unmodified lines | |||
| 62 | }); | 62 | }); |
| 63 | } | 63 | } |
| 64 | }); | 64 | }); |
| 65 | |||
| 66 | describe("app-plane dispatch", () => { | ||
| 67 | // These two are the whole risk of dispatching `/api` before Start: both still | ||
| 68 | // return a response if routed early, just the wrong one, so nothing else would | ||
| 69 | // catch a regression here. | ||
| 70 | it("leaves the Sentry tunnel POST to Start's middleware", () => { | ||
| 71 | expect(servedByAppPlane("/api/sentry-tunnel", "POST")).toBe(false); | ||
| 72 | // Only the POST is claimed; anything else under that path is ordinary API. | ||
| 73 | expect(servedByAppPlane("/api/sentry-tunnel", "GET")).toBe(true); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("leaves the OAuth callback to Start, for the signed-out redirect", () => { | ||
| 77 | expect(servedByAppPlane("/api/oauth/callback", "GET")).toBe(false); | ||
| 78 | expect(servedByAppPlane("/api/oauth/callback", "POST")).toBe(false); | ||
| 79 | }); | ||
| 80 | |||
| 81 | const appPlane = [ | ||
| 82 | "/api/connections", | ||
| 83 | "/api/tools", | ||
| 84 | "/api/integrations", | ||
| 85 | "/api/account/members", | ||
| 86 | "/api/docs", | ||
| 87 | "/api/billing/checkout", | ||
| 88 | ]; | ||
| 89 | for (const pathname of appPlane) { | ||
| 90 | it(`serves ${pathname} without entering Start`, () => { | ||
| 91 | expect(servedByAppPlane(pathname, "GET")).toBe(true); | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | |||
| 95 | it("never claims a non-API path, however app-owned", () => { | ||
| 96 | expect(servedByAppPlane("/mcp", "POST")).toBe(false); | ||
| 97 | expect(servedByAppPlane("/", "GET")).toBe(false); | ||
| 98 | expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false); | ||
| 99 | }); | ||
| 100 | }); | ||
| @@ -17,3 +17,31 @@ export const isApiPath = (pathname: string) => pathname === "/api" || pathname.s · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | export const isAppOwnedPath = (pathname: string) => | 18 | export const isAppOwnedPath = (pathname: string) => |
| 19 | isApiPath(pathname) || classifyMcpPath(pathname) !== null; | 19 | isApiPath(pathname) || classifyMcpPath(pathname) !== null; |
| 20 | |||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | // Which plane serves an app-owned path: the Effect app directly, or TanStack | ||
| 23 | // Start's middleware chain. | ||
| 24 | // | ||
| 25 | // Everything under `/api` is pure Effect and touches no part of the router, | ||
| 26 | // React, or SSR — so `server.ts` dispatches it at the Worker entry and skips | ||
| 27 | // Start's lazy `loadEntries` import entirely. Two paths must NOT take that | ||
| 28 | // shortcut, because Start's request middleware claims them BEFORE the app | ||
| 29 | // handler would ever see them: | ||
| 30 | // | ||
| 31 | // POST /api/sentry-tunnel - `sentryTunnelMiddleware` forwards the envelope | ||
| 32 | // to Sentry; the app has no such route. | ||
| 33 | // /api/oauth/callback - `oauthCallbackSignInMiddleware` redirects a | ||
| 34 | // signed-out visitor to /login, and start.ts | ||
| 35 | // rewrites the org-scoped `state` before handing | ||
| 36 | // off. Routing it early would drop both. | ||
| 37 | // | ||
| 38 | // Getting this wrong is silent: the request still gets a response, just the | ||
| 39 | // wrong one, which is why it is classified here and tested rather than being | ||
| 40 | // an inline condition at the dispatch site. | ||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | |||
| 43 | export const isStartOwnedApiPath = (pathname: string, method: string): boolean => | ||
| 44 | (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback"; | ||
| 45 | |||
| 46 | export const servedByAppPlane = (pathname: string, method: string): boolean => | ||
| 47 | isApiPath(pathname) && !isStartOwnedApiPath(pathname, method); | ||
| @@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; · 12 unmodified lines | |||
| 13 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; | 13 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; |
| 14 | import { DbService } from "./db/db"; | 14 | import { DbService } from "./db/db"; |
| 15 | import { cloudMcpAuth } from "./mcp"; | 15 | import { cloudMcpAuth } from "./mcp"; |
| 16 | import { McpSessionDOSqlite } from "./mcp/session-durable-object"; | ||
| 17 | import { ErrorCaptureLive } from "./observability"; | 16 | import { ErrorCaptureLive } from "./observability"; |
| 18 | import { AutumnService } from "./extensions/billing/service"; | 17 | import { AutumnService } from "./extensions/billing/service"; |
| 19 | import { | 18 | import { |
| @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines | |||
| 31 | // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- | 30 | // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- |
| 32 | // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, | 31 | // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, |
| 33 | // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object | 32 | // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object |
| 34 | // session store (the DO surfaced via `config.mcpExport`), console+Sentry error | 33 | // session store (the DO class is exported straight from server.ts), console+Sentry error |
| 35 | // capture — and Autumn BILLING entering ONLY as extensions: the engine | 34 | // capture — and Autumn BILLING entering ONLY as extensions: the engine |
| 36 | // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, | 35 | // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, |
| 37 | // and the createOrganization free-limit gate. `diff` against | 36 | // and the createOrganization free-limit gate. `diff` against |
| @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines | |||
| 66 | // satisfied by `boot`, `DbService` per request via `requestScoped`). | 65 | // satisfied by `boot`, `DbService` per request via `requestScoped`). |
| 67 | const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider; | 66 | const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider; |
| 68 | 67 | ||
| 69 | const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ | 68 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| 70 | plugins: cloudPlugins, | 69 | plugins: cloudPlugins, |
| 71 | providers: { | 70 | providers: { |
| 72 | // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS | 71 | // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS |
| @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines | |||
| 112 | failure: cloudIdentityFailureStrategy, | 111 | failure: cloudIdentityFailureStrategy, |
| 113 | // The MCP session Durable Object class — a top-level Workers export a Layer | 112 | // The MCP session Durable Object class — a top-level Workers export a Layer |
| 114 | // can't return; surfaced so `server.ts` can re-export it. | 113 | // can't return; surfaced so `server.ts` can re-export it. |
| 115 | mcpExport: McpSessionDOSqlite, | ||
| 116 | }, | 114 | }, |
| 117 | // The long-lived (boot-scoped) context provideMerge'd under everything: the | 115 | // The long-lived (boot-scoped) context provideMerge'd under everything: the |
| 118 | // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request | 116 | // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request |
| @@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 16 unmodified lines | |||
| 135 | requestScoped: RequestScopedServicesLive, | 133 | requestScoped: RequestScopedServicesLive, |
| 136 | }); | 134 | }); |
| 137 | 135 | ||
| 138 | export { McpSessionDOSqlite }; | ||
| 139 | |||
| 140 | export const CloudAppLayer = appLayer; | 136 | export const CloudAppLayer = appLayer; |
| 141 | export const cloudMcpExport = mcpExport; | ||
| 142 | 137 | ||
| 143 | // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), | 138 | // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), |
| 144 | // /mcp, /.well-known/* — everything the worker dispatches. | 139 | // /mcp, /.well-known/* — everything the worker dispatches. |
| @@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines | |||
| 42 | }); | 42 | }); |
| 43 | 43 | ||
| 44 | expect(principal).toEqual({ | 44 | expect(principal).toEqual({ |
| 45 | scope: "user", | ||
| 45 | accountId: "user_123", | 46 | accountId: "user_123", |
| 46 | organizationId: "org_123", | 47 | organizationId: "org_123", |
| 47 | keyId: "api_key_123", | 48 | keyId: "api_key_123", |
| @@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines | |||
| 66 | }), | 67 | }), |
| 67 | ); | 68 | ); |
| 68 | 69 | ||
| 69 | it.effect("rejects missing, organization-owned, and org-less keys", () => | 70 | it.effect("rejects missing and org-less keys", () => |
| 70 | Effect.gen(function* () { | 71 | Effect.gen(function* () { |
| 71 | const missing = yield* validate({ apiKey: null }); | 72 | const missing = yield* validate({ apiKey: null }); |
| 72 | const orgOwned = yield* validate({ | ||
| 73 | apiKey: { | ||
| 74 | id: "api_key_org", | ||
| 75 | owner: { type: "organization", id: "org_123" }, | ||
| 76 | }, | ||
| 77 | }); | ||
| 78 | const orgLess = yield* validate({ | 73 | const orgLess = yield* validate({ |
| 79 | apiKey: { | 74 | apiKey: { |
| 80 | id: "api_key_no_org", | 75 | id: "api_key_no_org", |
| @@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines | |||
| 83 | }); | 78 | }); |
| 84 | 79 | ||
| 85 | expect(missing).toBeNull(); | 80 | expect(missing).toBeNull(); |
| 86 | expect(orgOwned).toBeNull(); | ||
| 87 | expect(orgLess).toBeNull(); | 81 | expect(orgLess).toBeNull(); |
| 88 | }), | 82 | }), |
| 89 | ); | 83 | ); |
| 90 | 84 | ||
| 85 | it.effect("resolves organization-owned keys to the org scope with no account", () => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | // An org key USED to fail the decode and come back null — indistinguishable | ||
| 88 | // from an invalid key. It now resolves explicitly: `owner.id` IS the | ||
| 89 | // organization, and there is no member behind it. | ||
| 90 | const principal = yield* validate({ | ||
| 91 | apiKey: { | ||
| 92 | id: "api_key_org", | ||
| 93 | owner: { type: "organization", id: "org_123" }, | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | |||
| 97 | expect(principal).toEqual({ | ||
| 98 | scope: "org", | ||
| 99 | accountId: null, | ||
| 100 | organizationId: "org_123", | ||
| 101 | keyId: "api_key_org", | ||
| 102 | }); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 91 | it.effect("lists and creates user-owned keys", () => | 106 | it.effect("lists and creates user-owned keys", () => |
| 92 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 93 | const program = Effect.gen(function* () { | 108 | const program = Effect.gen(function* () { |
| @@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { ApiKeyManagementError } from "./errors"; | 3 | import { ApiKeyManagementError } from "./errors"; |
| 4 | import { WorkOSClient } from "./workos"; | 4 | import { WorkOSClient } from "./workos"; |
| 5 | 5 | ||
| 6 | /** | ||
| 7 | * Which view a validated key resolves to. | ||
| 8 | * - `"user"` — the product view. The key belongs to a member; the request | ||
| 9 | * binds to that member as the acting subject. Every key today is this. | ||
| 10 | * - `"org"` — the PLATFORM view. The key belongs to the organization itself, | ||
| 11 | * so there is no acting member: the request gets tenant-wide READ-ONLY | ||
| 12 | * reach and no bound subject. Privileged; minted separately (PR 1.5). | ||
| 13 | */ | ||
| 14 | export type ApiKeyScope = "user" | "org"; | ||
| 15 | |||
| 6 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / | 16 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / |
| 7 | * name / roles), so it carries an honest, distinct name. */ | 17 | * name / roles), so it carries an honest, distinct name. */ |
| 8 | export type ApiKeyOwner = { | 18 | export type ApiKeyOwner = { |
| 9 | readonly accountId: string; | 19 | readonly scope: ApiKeyScope; |
| 20 | /** The acting member for a `"user"` key; `null` for an `"org"` key, which | ||
| 21 | * has no member behind it. Nullable rather than a sentinel so nothing can | ||
| 22 | * accidentally bind an org key to a subject. */ | ||
| 23 | readonly accountId: string | null; | ||
| 10 | readonly organizationId: string; | 24 | readonly organizationId: string; |
| 11 | readonly keyId: string; | 25 | readonly keyId: string; |
| 12 | }; | 26 | }; |
| @@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines | |||
| 28 | readonly cause: unknown; | 42 | readonly cause: unknown; |
| 29 | }> {} | 43 | }> {} |
| 30 | 44 | ||
| 45 | /** | ||
| 46 | * The requested key id is not an org-owned key of this organization — it does | ||
| 47 | * not exist, belongs to a member rather than the org, or belongs to a different | ||
| 48 | * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an | ||
| 49 | * upstream failure) so callers can answer "not found" without a 500. | ||
| 50 | */ | ||
| 51 | export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{ | ||
| 52 | readonly keyId: string; | ||
| 53 | }> {} | ||
| 54 | |||
| 55 | // WorkOS keys are owned by either a user or an organization. The user shape | ||
| 56 | // carries the member id in `id` and the org in `organization_id`; the org shape | ||
| 57 | // carries the ORG in `id` and no member at all. Both decode here — the | ||
| 58 | // distinction is a branch (`ownerFromApiKey`), not a decode failure, so an | ||
| 59 | // org-owned key is no longer indistinguishable from an invalid one. | ||
| 31 | const UserApiKeyOwner = Schema.Struct({ | 60 | const UserApiKeyOwner = Schema.Struct({ |
| 32 | type: Schema.Literal("user"), | 61 | type: Schema.Literal("user"), |
| 33 | id: Schema.String, | 62 | id: Schema.String, |
| @@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line | |||
| 35 | organization_id: Schema.optional(Schema.String), | 64 | organization_id: Schema.optional(Schema.String), |
| 36 | }); | 65 | }); |
| 37 | 66 | ||
| 67 | const OrgApiKeyOwner = Schema.Struct({ | ||
| 68 | type: Schema.Literal("organization"), | ||
| 69 | id: Schema.String, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]); | ||
| 73 | |||
| 38 | const ApiKey = Schema.Struct({ | 74 | const ApiKey = Schema.Struct({ |
| 39 | id: Schema.String, | 75 | id: Schema.String, |
| 40 | owner: UserApiKeyOwner, | 76 | owner: UserApiKeyOwner, |
| @@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines | |||
| 49 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), | 85 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 50 | }); | 86 | }); |
| 51 | 87 | ||
| 88 | // Validation accepts BOTH owner shapes. The user list/create paths below stay | ||
| 89 | // user-only on purpose (they are the console's personal-key CRUD); the ORG | ||
| 90 | // list/create paths are the separate, privileged surface beside them, gated to | ||
| 91 | // admins at the account-provider boundary. | ||
| 92 | const ValidatedApiKey = Schema.Struct({ | ||
| 93 | id: Schema.String, | ||
| 94 | owner: WorkOSApiKeyOwner, | ||
| 95 | }); | ||
| 96 | |||
| 52 | const ValidateApiKeyResponse = Schema.Struct({ | 97 | const ValidateApiKeyResponse = Schema.Struct({ |
| 53 | apiKey: Schema.NullOr(ApiKey), | 98 | apiKey: Schema.NullOr(ValidatedApiKey), |
| 54 | }); | 99 | }); |
| 55 | 100 | ||
| 56 | const RawCreatedApiKey = Schema.Struct({ | 101 | const RawCreatedApiKey = Schema.Struct({ |
| @@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines | |||
| 72 | data: Schema.Array(ApiKey), | 117 | data: Schema.Array(ApiKey), |
| 73 | }); | 118 | }); |
| 74 | 119 | ||
| 120 | // The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape | ||
| 121 | // rather than a widened one: `summaryFromApiKey` reads the user shape's | ||
| 122 | // `organization_id` to prove the key belongs to the caller's org, and an | ||
| 123 | // org-owned row carries the org in `owner.id` instead. Merging them would mean | ||
| 124 | // one nullable field standing for two different things. | ||
| 125 | const OrgApiKey = Schema.Struct({ | ||
| 126 | id: Schema.String, | ||
| 127 | owner: OrgApiKeyOwner, | ||
| 128 | name: Schema.optional(Schema.String), | ||
| 129 | obfuscatedValue: Schema.optional(Schema.String), | ||
| 130 | obfuscated_value: Schema.optional(Schema.String), | ||
| 131 | createdAt: Schema.optional(Schema.String), | ||
| 132 | created_at: Schema.optional(Schema.String), | ||
| 133 | updatedAt: Schema.optional(Schema.String), | ||
| 134 | updated_at: Schema.optional(Schema.String), | ||
| 135 | lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 136 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 137 | }); | ||
| 138 | |||
| 139 | const RawCreatedOrgApiKey = Schema.Struct({ | ||
| 140 | ...OrgApiKey.fields, | ||
| 141 | value: Schema.String, | ||
| 142 | }); | ||
| 143 | |||
| 144 | // `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS | ||
| 145 | // may return user-owned keys alongside org-owned ones, and a whole-array decode | ||
| 146 | // fails on the first non-org element — blanking the entire list rather than | ||
| 147 | // dropping the one row that doesn't belong. Same per-row tolerance the user-key | ||
| 148 | // path gets from `summaryFromApiKey` returning null. | ||
| 149 | const ListOrgApiKeysResponse = Schema.Struct({ | ||
| 150 | data: Schema.Array(Schema.Unknown), | ||
| 151 | }); | ||
| 152 | |||
| 153 | const CreateOrgApiKeyResponse = Schema.Union([ | ||
| 154 | RawCreatedOrgApiKey, | ||
| 155 | Schema.Struct({ apiKey: RawCreatedOrgApiKey }), | ||
| 156 | Schema.Struct({ api_key: RawCreatedOrgApiKey }), | ||
| 157 | ]); | ||
| 158 | |||
| 75 | const CreateApiKeyResponse = Schema.Union([ | 159 | const CreateApiKeyResponse = Schema.Union([ |
| 76 | RawCreatedApiKey, | 160 | RawCreatedApiKey, |
| 77 | Schema.Struct({ apiKey: RawCreatedApiKey }), | 161 | Schema.Struct({ apiKey: RawCreatedApiKey }), |
| @@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines | |||
| 81 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); | 165 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); |
| 82 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); | 166 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); |
| 83 | const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); | 167 | const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); |
| 168 | const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse); | ||
| 169 | const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey); | ||
| 170 | const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse); | ||
| 171 | |||
| 172 | const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => { | ||
| 173 | if (apiKey.owner.type === "organization") { | ||
| 174 | // An org-owned key IS the org: `owner.id` is the organization, and there | ||
| 175 | // is no member to act as. This resolves to the platform view. | ||
| 176 | return { | ||
| 177 | scope: "org", | ||
| 178 | accountId: null, | ||
| 179 | organizationId: apiKey.owner.id, | ||
| 180 | keyId: apiKey.id, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | ||
| 184 | if (!organizationId) return null; | ||
| 185 | return { | ||
| 186 | scope: "user", | ||
| 187 | accountId: apiKey.owner.id, | ||
| 188 | organizationId, | ||
| 189 | keyId: apiKey.id, | ||
| 190 | }; | ||
| 191 | }; | ||
| 84 | 192 | ||
| 85 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => | 193 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => |
| 86 | Option.match(decodeValidateApiKeyResponse(value), { | 194 | Option.match(decodeValidateApiKeyResponse(value), { |
| 87 | onNone: () => null, | 195 | onNone: () => null, |
| 88 | onSome: ({ apiKey }) => { | 196 | onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), |
| 89 | if (!apiKey) return null; | ||
| 90 | const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | ||
| 91 | if (!organizationId) return null; | ||
| 92 | return { | ||
| 93 | accountId: apiKey.owner.id, | ||
| 94 | organizationId, | ||
| 95 | keyId: apiKey.id, | ||
| 96 | }; | ||
| 97 | }, | ||
| 98 | }); | 197 | }); |
| 99 | 198 | ||
| 100 | const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { | 199 | const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { |
| @@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines | |||
| 120 | }), | 219 | }), |
| 121 | }); | 220 | }); |
| 122 | 221 | ||
| 222 | // The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by | ||
| 223 | // the CALLER against the org it asked for: WorkOS's workspace api key is | ||
| 224 | // workspace-wide, so a response naming a different org than the one requested | ||
| 225 | // must not be surfaced as that org's key. | ||
| 226 | const summaryFromOrgApiKey = ( | ||
| 227 | apiKey: typeof OrgApiKey.Type, | ||
| 228 | organizationId: string, | ||
| 229 | ): ApiKeySummary | null => { | ||
| 230 | if (apiKey.owner.id !== organizationId) return null; | ||
| 231 | return { | ||
| 232 | id: apiKey.id, | ||
| 233 | name: apiKey.name ?? "API key", | ||
| 234 | obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "", | ||
| 235 | createdAt: apiKey.createdAt ?? apiKey.created_at ?? "", | ||
| 236 | updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "", | ||
| 237 | lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null, | ||
| 238 | }; | ||
| 239 | }; | ||
| 240 | |||
| 241 | const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] => | ||
| 242 | Option.match(decodeListOrgApiKeysResponse(value), { | ||
| 243 | onNone: () => [], | ||
| 244 | onSome: ({ data }) => | ||
| 245 | data.flatMap((entry) => | ||
| 246 | Option.match(decodeOrgApiKey(entry), { | ||
| 247 | // Not an org-owned key (a user key sharing the listing, or a shape we | ||
| 248 | // don't recognize) — skip the row, keep the rest. | ||
| 249 | onNone: () => [], | ||
| 250 | onSome: (apiKey) => { | ||
| 251 | const summary = summaryFromOrgApiKey(apiKey, organizationId); | ||
| 252 | return summary ? [summary] : []; | ||
| 253 | }, | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | }); | ||
| 257 | |||
| 258 | const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null => | ||
| 259 | Option.match(decodeCreateOrgApiKeyResponse(value), { | ||
| 260 | onNone: () => null, | ||
| 261 | onSome: (response) => { | ||
| 262 | const apiKey = | ||
| 263 | "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key; | ||
| 264 | const summary = summaryFromOrgApiKey(apiKey, organizationId); | ||
| 265 | return summary ? { ...summary, value: apiKey.value } : null; | ||
| 266 | }, | ||
| 267 | }); | ||
| 268 | |||
| 123 | const createdFromResponse = (value: unknown): CreatedApiKey | null => | 269 | const createdFromResponse = (value: unknown): CreatedApiKey | null => |
| 124 | Option.match(decodeCreateApiKeyResponse(value), { | 270 | Option.match(decodeCreateApiKeyResponse(value), { |
| 125 | onNone: () => null, | 271 | onNone: () => null, |
| @@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines | |||
| 147 | readonly revokeUserKey: (input: { | 293 | readonly revokeUserKey: (input: { |
| 148 | readonly keyId: string; | 294 | readonly keyId: string; |
| 149 | }) => Effect.Effect<void, ApiKeyManagementError>; | 295 | }) => Effect.Effect<void, ApiKeyManagementError>; |
| 296 | /** | ||
| 297 | * The org-owned keys of an organization — the credentials that resolve to | ||
| 298 | * the read-only platform view. Privileged: callers gate on admin membership | ||
| 299 | * before reaching this. | ||
| 300 | */ | ||
| 301 | readonly listOrgKeys: (input: { | ||
| 302 | readonly organizationId: string; | ||
| 303 | }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>; | ||
| 304 | readonly createOrgKey: (input: { | ||
| 305 | readonly organizationId: string; | ||
| 306 | readonly name: string; | ||
| 307 | }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>; | ||
| 308 | /** | ||
| 309 | * Revoke an org-owned key. Ownership is resolved against the ORG's own key | ||
| 310 | * listing — never the caller's personal keys — so a member's user key id | ||
| 311 | * (or another org's key id, since the WorkOS workspace key is | ||
| 312 | * workspace-wide) cannot be destroyed through this path. | ||
| 313 | */ | ||
| 314 | readonly revokeOrgKey: (input: { | ||
| 315 | readonly organizationId: string; | ||
| 316 | readonly keyId: string; | ||
| 317 | }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>; | ||
| 150 | } | 318 | } |
| 151 | >()("@executor-js/cloud/ApiKeyService") { | 319 | >()("@executor-js/cloud/ApiKeyService") { |
| 152 | static WorkOS = Layer.effect(this)( | 320 | static WorkOS = Layer.effect(this)( |
| @@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines | |||
| 177 | workos | 345 | workos |
| 178 | .deleteApiKey(keyId) | 346 | .deleteApiKey(keyId) |
| 179 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))), | 347 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))), |
| 348 | listOrgKeys: ({ organizationId }) => | ||
| 349 | workos.listOrgApiKeys(organizationId).pipe( | ||
| 350 | Effect.map((response) => orgListFromResponse(response, organizationId)), | ||
| 351 | Effect.mapError((cause) => new ApiKeyManagementError({ cause })), | ||
| 352 | ), | ||
| 353 | createOrgKey: ({ organizationId, name }) => | ||
| 354 | workos.createOrgApiKey({ organizationId, name }).pipe( | ||
| 355 | Effect.mapError((cause) => new ApiKeyManagementError({ cause })), | ||
| 356 | Effect.flatMap((response) => { | ||
| 357 | const created = orgCreatedFromResponse(response, organizationId); | ||
| 358 | return created | ||
| 359 | ? Effect.succeed(created) | ||
| 360 | : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" })); | ||
| 361 | }), | ||
| 362 | ), | ||
| 363 | // Ownership is checked against the ORG's decoded key listing before the | ||
| 364 | // delete goes out. `deleteApiKey` is scope-blind — handed any key id it | ||
| 365 | // would happily destroy a member's personal credential, or a key | ||
| 366 | // belonging to a sibling org in the same workspace. The listing already | ||
| 367 | // drops both of those (`orgListFromResponse` filters on owner type AND | ||
| 368 | // organization), so filtering through it is the whole authorization. | ||
| 369 | revokeOrgKey: ({ organizationId, keyId }) => | ||
| 370 | Effect.gen(function* () { | ||
| 371 | const response = yield* workos | ||
| 372 | .listOrgApiKeys(organizationId) | ||
| 373 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); | ||
| 374 | const owned = orgListFromResponse(response, organizationId); | ||
| 375 | if (!owned.some((key) => key.id === keyId)) { | ||
| 376 | return yield* new OrgApiKeyNotFound({ keyId }); | ||
| 377 | } | ||
| 378 | yield* workos | ||
| 379 | .deleteApiKey(keyId) | ||
| 380 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); | ||
| 381 | }), | ||
| 180 | }; | 382 | }; |
| 181 | }), | 383 | }), |
| 182 | ); | 384 | ); |
| @@ -181,6 +181,15 @@ const McpApprovalErrors = [ · 180 unmodified lines | |||
| 181 | /** Public auth endpoints — no authentication required */ | 181 | /** Public auth endpoints — no authentication required */ |
| 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") | 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") |
| 183 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) | 183 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) |
| 184 | // Sign-out is PUBLIC on purpose. The console posts it as a top-level form | ||
| 185 | // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which | ||
| 186 | // means an error response is rendered as the page — and a browser whose | ||
| 187 | // session has already ended is exactly the browser most likely to click it | ||
| 188 | // (a second tab, a re-submit from history, an expired or revoked session). | ||
| 189 | // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen | ||
| 190 | // instead of being signed out. There is nothing to authorize here anyway: | ||
| 191 | // the request can only end the session whose cookie it presents. | ||
| 192 | .add(HttpApiEndpoint.post("logout", "/auth/logout")) | ||
| 184 | .add( | 193 | .add( |
| 185 | HttpApiEndpoint.get("callback", "/auth/callback", { | 194 | HttpApiEndpoint.get("callback", "/auth/callback", { |
| 186 | query: AuthCallbackSearch, | 195 | query: AuthCallbackSearch, |
| @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines | |||
| 201 | error: AuthErrors, | 210 | error: AuthErrors, |
| 202 | }), | 211 | }), |
| 203 | ) | 212 | ) |
| 204 | .add(HttpApiEndpoint.post("logout", "/auth/logout")) | ||
| 205 | .add( | 213 | .add( |
| 206 | HttpApiEndpoint.get("organizations", "/auth/organizations", { | 214 | HttpApiEndpoint.get("organizations", "/auth/organizations", { |
| 207 | success: AuthOrganizationsResponse, | 215 | success: AuthOrganizationsResponse, |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { makeUserStore } from "../auth/user-store"; | 2 | import { makeUserStore } from "../auth/user-store"; |
| 3 | import { DbService } from "../db/db"; | 3 | import { DbService } from "../db/db"; |
| 4 | import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; | 4 | import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors"; |
| 5 | 5 | ||
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect | 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect |
| @@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 1 unmodified line | |||
| 9 | 9 | ||
| 10 | type RawStore = ReturnType<typeof makeUserStore>; | 10 | type RawStore = ReturnType<typeof makeUserStore>; |
| 11 | 11 | ||
| 12 | // `op` names the store call so every span reads `user_store.<operation>` | ||
| 13 | // instead of one undifferentiated "user_store" bucket, failures log which | ||
| 14 | // query actually failed, and — because the same `op` is threaded onto the | ||
| 15 | // public error alongside the classified driver reason — an error report is | ||
| 16 | // diagnosable without the trace. | ||
| 12 | const makeService = (store: RawStore) => ({ | 17 | const makeService = (store: RawStore) => ({ |
| 13 | use: <A>(fn: (s: RawStore) => Promise<A>) => | 18 | use: <A>(op: string, fn: (s: RawStore) => Promise<A>) => |
| 14 | withServiceLogging( | 19 | withServiceLogging( |
| 15 | "user_store", | 20 | `user_store.${op}`, |
| 16 | () => new UserStoreError(), | 21 | (failure) => userStoreErrorFromFailure(op, failure), |
| 17 | tryPromiseService(() => fn(store)), | 22 | tryPromiseService(() => fn(store)), |
| 18 | ), | 23 | ), |
| 19 | }); | 24 | }); |
| @@ -1,19 +1,23 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // SSR auth gate — the server-side session check for DOCUMENT requests. | 2 | // Document auth gate — the server-side session check for DOCUMENT requests. |
| 3 | // | 3 | // |
| 4 | // The sealed `wos-session` cookie is verified right here in the worker | 4 | // The sealed `wos-session` cookie is verified right here in the worker |
| 5 | // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip | 5 | // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip |
| 6 | // except token refresh), so by the time the SPA is served the server KNOWS | 6 | // except token refresh), so by the time the SPA shell is served the server |
| 7 | // who it's serving: | 7 | // KNOWS who it's serving: |
| 8 | // | 8 | // |
| 9 | // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists | 9 | // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists |
| 10 | // - org-less → 302 /create-org (onboarding owns those sessions) | 10 | // - org-less → 302 /create-org (onboarding owns those sessions) |
| 11 | // - signed in → the document is served WITH the verified identity: the | 11 | // - signed in → the prerendered shell is served, and the auth-hint cookie is |
| 12 | // auth-hint travels to the SSR render via request-middleware context (the | 12 | // minted when the browser doesn't hold a current one — the client seeds its |
| 13 | // root loader picks it up), and is minted as a cookie when the browser | 13 | // auth state from that cookie right after mount (there is no per-request |
| 14 | // doesn't hold a current one — so the very first paint is the real app | 14 | // render to dehydrate it into). The hint is display-only; /account/me |
| 15 | // shell, never a skeleton. The hint is display-only; /account/me remains | 15 | // remains the authority and the client keeps it fresh from then on. |
| 16 | // the authority and the client keeps it fresh from then on. | 16 | // |
| 17 | // Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS | ||
| 18 | // refresh tokens are single-use, and a verify that rotates the sealed session | ||
| 19 | // must deliver the new cookie to the browser or the next expiry logs the user | ||
| 20 | // out. Document navigations are where that rotation lands. | ||
| 17 | // | 21 | // |
| 18 | // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / | 22 | // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / |
| 19 | // accept), excluding app-owned paths (/api, /mcp — they answer for themselves | 23 | // accept), excluding app-owned paths (/api, /mcp — they answer for themselves |
| @@ -23,6 +27,7 @@ · 3 unmodified lines | |||
| 23 | import { createMiddleware } from "@tanstack/react-start"; | 27 | import { createMiddleware } from "@tanstack/react-start"; |
| 24 | import { Effect, Exit, Layer, ManagedRuntime } from "effect"; | 28 | import { Effect, Exit, Layer, ManagedRuntime } from "effect"; |
| 25 | 29 | ||
| 30 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 26 | import { | 31 | import { |
| 27 | AUTH_HINT_COOKIE, | 32 | AUTH_HINT_COOKIE, |
| 28 | AUTH_HINT_MAX_AGE_SECONDS, | 33 | AUTH_HINT_MAX_AGE_SECONDS, |
| @@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines | |||
| 35 | import { makeDbLayer } from "../db/db"; | 40 | import { makeDbLayer } from "../db/db"; |
| 36 | import { makeUserStoreLayer, UserStoreService } from "./context"; | 41 | import { makeUserStoreLayer, UserStoreService } from "./context"; |
| 37 | import { parseCookie } from "./cookies"; | 42 | import { parseCookie } from "./cookies"; |
| 43 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; | ||
| 38 | import { sealedSessionDisplayName } from "./middleware"; | 44 | import { sealedSessionDisplayName } from "./middleware"; |
| 39 | import { browserOriginFromRequest } from "./request-origin"; | 45 | import { authorizeOrganizationSelector } from "./organization"; |
| 40 | import { loginPath, safeReturnTo } from "./return-to"; | 46 | import { loginPath, safeReturnTo } from "./return-to"; |
| 41 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; | 47 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; |
| 42 | import { WorkOSClient } from "./workos"; | 48 | import { WorkOSClient } from "./workos"; |
| @@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines | |||
| 150 | ): Promise<{ name: string; slug: string }> => { | 156 | ): Promise<{ name: string; slug: string }> => { |
| 151 | const exit = await getRuntime().runPromiseExit( | 157 | const exit = await getRuntime().runPromiseExit( |
| 152 | Effect.flatMap(UserStoreService.asEffect(), (users) => | 158 | Effect.flatMap(UserStoreService.asEffect(), (users) => |
| 153 | users.use((store) => store.getOrganization(organizationId)), | 159 | users.use("getOrganization", (store) => store.getOrganization(organizationId)), |
| 154 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), | 160 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), |
| 155 | ); | 161 | ); |
| 156 | return Exit.isSuccess(exit) | 162 | return Exit.isSuccess(exit) |
| @@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line | |||
| 158 | : { name: "", slug: "" }; | 164 | : { name: "", slug: "" }; |
| 159 | }; | 165 | }; |
| 160 | 166 | ||
| 167 | // Live membership check for the last-org cookie's slug. Same authorize path | ||
| 168 | // as any org selector — the cookie is a preference, so a slug the user can't | ||
| 169 | // access (stale after removal/deletion, or forged) resolves to null and the | ||
| 170 | // bare path falls through to today's canonicalize-onto-session-org behavior. | ||
| 171 | // Per-request store layers for the same reason as organizationDisplay. | ||
| 172 | const authorizeLastOrgSlug = async ( | ||
| 173 | userId: string, | ||
| 174 | slug: string, | ||
| 175 | ): Promise<{ readonly id: string } | null> => { | ||
| 176 | const exit = await getRuntime().runPromiseExit( | ||
| 177 | authorizeOrganizationSelector(userId, slug).pipe( | ||
| 178 | Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | return Exit.isSuccess(exit) ? exit.value : null; | ||
| 182 | }; | ||
| 183 | |||
| 161 | const hintSetCookie = (hint: AuthHint) => | 184 | const hintSetCookie = (hint: AuthHint) => |
| 162 | `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; | 185 | `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; |
| 163 | 186 | ||
| @@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 62 unmodified lines | |||
| 226 | return redirect("/create-org", { refreshedSession: session.refreshedSession }); | 249 | return redirect("/create-org", { refreshedSession: session.refreshedSession }); |
| 227 | } | 250 | } |
| 228 | 251 | ||
| 229 | // Serve the document WITH the verified identity: the hint rides to the | 252 | // A BARE console path (no org slug in the URL) canonicalizes onto the org |
| 230 | // SSR render through middleware context (the root loader reads it), so | 253 | // this browser last worked in (the last-org cookie), not the org pinned in |
| 231 | // the server paints the real authenticated shell — no loading state, no | 254 | // the session at login time — a multi-org user re-entering at `/` lands |
| 232 | // skeleton. The request origin rides along too: it's what the connect | 255 | // where they left off. Slugged URLs never enter here (the URL names its |
| 233 | // card's MCP URL is built from, and the server knows it (the SPA only | 256 | // own scope), so this costs nothing on the steady state; the not-found |
| 234 | // learns `window.location.origin` after mount), so passing it here lets | 257 | // contract is untouched because an unknown-but-valid slug in the URL reads |
| 235 | // SSR render the real `https://…/<org>/mcp` instead of the client-side | 258 | // as slugged, not bare. When the cookie matches the session's own org (the |
| 236 | // `http://127.0.0.1:4000` default — which would otherwise flash until | 259 | // overwhelmingly common single-org case) the client-side OrgSlugGate |
| 237 | // hydration corrected it. Set-cookie writes ride on the rendered response. | 260 | // already canonicalizes onto it, so skip the live membership check and the |
| 261 | // redirect entirely. | ||
| 262 | const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE); | ||
| 263 | const firstSegment = pathname.split("/")[1] ?? ""; | ||
| 264 | if ( | ||
| 265 | session.organizationId && | ||
| 266 | lastOrgSlug && | ||
| 267 | isValidOrgSlug(lastOrgSlug) && | ||
| 268 | !isValidOrgSlug(firstSegment) && | ||
| 269 | !ONBOARDING_PATHS.has(pathname) | ||
| 270 | ) { | ||
| 271 | const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug; | ||
| 272 | if (lastOrgSlug !== sessionOrgSlug) { | ||
| 273 | // The cookie is a preference, not an authority: only redirect onto an | ||
| 274 | // org the caller actively belongs to (stale/forged values fall through | ||
| 275 | // to today's session-org canonicalization). | ||
| 276 | const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug); | ||
| 277 | if (lastOrg) { | ||
| 278 | return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, { | ||
| 279 | refreshedSession: session.refreshedSession, | ||
| 280 | }); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | // Serve the shell, minting the auth-hint cookie when the browser lacks a | ||
| 286 | // current one so the client's post-mount cookie read seeds the verified | ||
| 287 | // identity. Set-cookie writes ride on the shell response. | ||
| 238 | const { hint, mint } = await resolveAuthHint(session, cookieHeader); | 288 | const { hint, mint } = await resolveAuthHint(session, cookieHeader); |
| 239 | const result = await next({ | 289 | const result = await next(); |
| 240 | context: { authHint: hint, origin: browserOriginFromRequest(request) }, | ||
| 241 | }); | ||
| 242 | if (!mint && !session.refreshedSession) return result; | 290 | if (!mint && !session.refreshedSession) return result; |
| 243 | 291 | ||
| 244 | const response = new Response(result.response.body, result.response); | 292 | const response = new Response(result.response.body, result.response); |
| @@ -1,10 +1,103 @@ | |||
| 1 | import { Data, Effect, Option, Predicate, Schema } from "effect"; | 1 | import { Data, Effect, Option, Predicate, Schema } from "effect"; |
| 2 | 2 | ||
| 3 | // How a user-store call failed, classified from the driver cause. Safe to put | ||
| 4 | // on the wire and on a Sentry tag: it names a failure MODE, never a query, a | ||
| 5 | // value, or a customer. | ||
| 6 | export const USER_STORE_FAILURE_REASONS = [ | ||
| 7 | "connect_timeout", | ||
| 8 | "connection_closed", | ||
| 9 | "query", | ||
| 10 | "unknown", | ||
| 11 | ] as const; | ||
| 12 | |||
| 13 | export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number]; | ||
| 14 | |||
| 15 | /** | ||
| 16 | * The public failure of every cloud user-store call. | ||
| 17 | * | ||
| 18 | * It carries the two fields that make an issue diagnosable from the error | ||
| 19 | * alone: which store call failed, and how. Before those existed the error had | ||
| 20 | * an empty field set, so Sentry showed a titleless, messageless issue and the | ||
| 21 | * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is | ||
| 22 | * scrubbed server-side — the failing operation and the driver reason existed | ||
| 23 | * only in the trace store. Same shape as `WorkOSError.status`: a small, safe | ||
| 24 | * classification field threaded at the service boundary. | ||
| 25 | */ | ||
| 3 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( | 26 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( |
| 4 | "UserStoreError", | 27 | "UserStoreError", |
| 5 | {}, | 28 | { |
| 29 | /** The store call that failed, e.g. `getOrganization`. */ | ||
| 30 | operation: Schema.String, | ||
| 31 | /** How it failed, classified from the driver cause chain. */ | ||
| 32 | reason: Schema.Literals(USER_STORE_FAILURE_REASONS), | ||
| 33 | }, | ||
| 6 | { httpApiStatus: 500 }, | 34 | { httpApiStatus: 500 }, |
| 7 | ) {} | 35 | ) { |
| 36 | override get message(): string { | ||
| 37 | return `user store ${this.operation} failed: ${this.reason}`; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /** Reasons a retry can plausibly clear: the query never reached a healthy | ||
| 42 | * server. A `query` failure is deterministic and must not be retried. */ | ||
| 43 | export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean => | ||
| 44 | reason === "connect_timeout" || reason === "connection_closed"; | ||
| 45 | |||
| 46 | // postgres.js tags its connection failures with a string `code` | ||
| 47 | // (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the | ||
| 48 | // SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with | ||
| 49 | // the driver error in `.cause`, and the service adapter wraps that again, so | ||
| 50 | // the classification walks the chain rather than inspecting one level. | ||
| 51 | const MAX_CAUSE_DEPTH = 8; | ||
| 52 | |||
| 53 | const REASON_BY_DRIVER_CODE: Readonly<Record<string, UserStoreFailureReason>> = { | ||
| 54 | CONNECT_TIMEOUT: "connect_timeout", | ||
| 55 | ETIMEDOUT: "connect_timeout", | ||
| 56 | CONNECTION_CLOSED: "connection_closed", | ||
| 57 | CONNECTION_ENDED: "connection_closed", | ||
| 58 | CONNECTION_DESTROYED: "connection_closed", | ||
| 59 | ECONNREFUSED: "connection_closed", | ||
| 60 | ECONNRESET: "connection_closed", | ||
| 61 | }; | ||
| 62 | |||
| 63 | const stringCodeOf = (value: unknown): string | undefined => { | ||
| 64 | if (typeof value !== "object" || value === null) return undefined; | ||
| 65 | const code = (value as { readonly code?: unknown }).code; | ||
| 66 | return typeof code === "string" ? code : undefined; | ||
| 67 | }; | ||
| 68 | |||
| 69 | const driverCodesOf = (failure: unknown): readonly string[] => { | ||
| 70 | const codes: string[] = []; | ||
| 71 | let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure; | ||
| 72 | for ( | ||
| 73 | let depth = 0; | ||
| 74 | depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; | ||
| 75 | depth++ | ||
| 76 | ) { | ||
| 77 | const code = stringCodeOf(current); | ||
| 78 | if (code !== undefined) codes.push(code); | ||
| 79 | current = | ||
| 80 | typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined; | ||
| 81 | } | ||
| 82 | return codes; | ||
| 83 | }; | ||
| 84 | |||
| 85 | /** Classify a raw store failure. A recognised connection code wins; any other | ||
| 86 | * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is | ||
| 87 | * `unknown`. */ | ||
| 88 | export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => { | ||
| 89 | const codes = driverCodesOf(failure); | ||
| 90 | for (const code of codes) { | ||
| 91 | const reason = REASON_BY_DRIVER_CODE[code]; | ||
| 92 | if (reason !== undefined) return reason; | ||
| 93 | } | ||
| 94 | return codes.length > 0 ? "query" : "unknown"; | ||
| 95 | }; | ||
| 96 | |||
| 97 | /** Build the public `UserStoreError` for a store-adapter failure, naming the | ||
| 98 | * operation the call site already knows and classifying the driver cause. */ | ||
| 99 | export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError => | ||
| 100 | new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) }); | ||
| 8 | 101 | ||
| 9 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( | 102 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( |
| 10 | "WorkOSError", | 103 | "WorkOSError", |
| @@ -22,11 +22,13 @@ import { env } from "cloudflare:workers"; · 21 unmodified lines | |||
| 22 | import { WorkOSError } from "./errors"; | 22 | import { WorkOSError } from "./errors"; |
| 23 | import { WorkOSClient } from "./workos"; | 23 | import { WorkOSClient } from "./workos"; |
| 24 | import { AutumnService } from "../extensions/billing/service"; | 24 | import { AutumnService } from "../extensions/billing/service"; |
| 25 | import { captureCauseEffect } from "../observability"; | ||
| 25 | import { | 26 | import { |
| 26 | hasPaidOrganizationSubscription, | 27 | hasPaidOrganizationSubscription, |
| 27 | isOverFreeOrganizationLimit, | 28 | isOverFreeOrganizationLimit, |
| 28 | shouldApplyFreeOrganizationLimit, | 29 | shouldApplyFreeOrganizationLimit, |
| 29 | } from "../extensions/billing/plans"; | 30 | } from "../extensions/billing/plans"; |
| 31 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; | ||
| 30 | import { | 32 | import { |
| 31 | ORG_SELECTOR_HEADER, | 33 | ORG_SELECTOR_HEADER, |
| 32 | authorizeOrganization, | 34 | authorizeOrganization, |
| @@ -143,8 +145,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines | |||
| 143 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); | 145 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); |
| 144 | 146 | ||
| 145 | // --------------------------------------------------------------------------- | 147 | // --------------------------------------------------------------------------- |
| 146 | // Single non-protected API surface — public (login/callback) + session | 148 | // Single non-protected API surface — public (login/callback/logout) + session |
| 147 | // (me/logout/organizations/switch-organization). The session group has SessionAuth on it. | 149 | // (me/organizations/switch-organization). The session group has SessionAuth on it. |
| 148 | // --------------------------------------------------------------------------- | 150 | // --------------------------------------------------------------------------- |
| 149 | 151 | ||
| 150 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); | 152 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); |
| @@ -201,7 +203,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines | |||
| 201 | const result = yield* workos.authenticateWithCode(query.code); | 203 | const result = yield* workos.authenticateWithCode(query.code); |
| 202 | 204 | ||
| 203 | // Mirror the account locally | 205 | // Mirror the account locally |
| 204 | yield* users.use((s) => s.ensureAccount(result.user.id)); | 206 | yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id)); |
| 205 | 207 | ||
| 206 | let sealedSession = result.sealedSession; | 208 | let sealedSession = result.sealedSession; |
| 207 | 209 | ||
| @@ -218,11 +220,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines | |||
| 218 | : null; | 220 | : null; |
| 219 | 221 | ||
| 220 | // Prefer the org in the URL that sent the user to login. If the URL | 222 | // Prefer the org in the URL that sent the user to login. If the URL |
| 221 | // is bare, or not an org route, fall back to WorkOS's org and then to | 223 | // is bare, or not an org route, prefer the org this browser last |
| 222 | // the first active membership for org-less sessions. Pending | 224 | // worked in (the last-org cookie — it outlives the session precisely |
| 223 | // memberships are skipped because refreshing into one 400s and would | 225 | // so a fresh login lands where the user left off), then WorkOS's |
| 224 | // bypass invite consent. | 226 | // org, then the first active membership for org-less sessions. |
| 225 | let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null; | 227 | // Pending memberships are skipped because refreshing into one 400s |
| 228 | // and would bypass invite consent. The cookie is membership-checked | ||
| 229 | // like any selector, so a stale one just falls through. | ||
| 230 | let targetOrganizationId = requestedOrg?.id ?? null; | ||
| 231 | if (!targetOrganizationId && !requestedOrgSelector) { | ||
| 232 | const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; | ||
| 233 | const lastOrg = | ||
| 234 | lastOrgSlug && isValidOrgSlug(lastOrgSlug) | ||
| 235 | ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe( | ||
| 236 | Effect.orElseSucceed(() => null), | ||
| 237 | ) | ||
| 238 | : null; | ||
| 239 | targetOrganizationId = lastOrg?.id ?? null; | ||
| 240 | } | ||
| 241 | targetOrganizationId ??= result.organizationId ?? null; | ||
| 226 | if (!targetOrganizationId && !requestedOrgSelector) { | 242 | if (!targetOrganizationId && !requestedOrgSelector) { |
| 227 | const memberships = yield* workos.listUserMemberships(result.user.id); | 243 | const memberships = yield* workos.listUserMemberships(result.user.id); |
| 228 | const existingActive = memberships.data.find((m) => m.status === "active"); | 244 | const existingActive = memberships.data.find((m) => m.status === "active"); |
| @@ -259,6 +275,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 30 unmodified lines | |||
| 259 | ); | 275 | ); |
| 260 | }), | 276 | }), |
| 261 | ) | 277 | ) |
| 278 | .handleRaw("logout", ({ request }) => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const workos = yield* WorkOSClient; | ||
| 281 | // The session this browser presents, NOT one the middleware vouched | ||
| 282 | // for — signing out of a session that has already ended must still | ||
| 283 | // sign the browser out (see the group declaration in ./api.ts). | ||
| 284 | const sealedSession = request.cookies["wos-session"] ?? ""; | ||
| 285 | |||
| 286 | // WorkOS's documented sign-out: send the browser through the WorkOS | ||
| 287 | // logout endpoint, which ends the AuthKit session upstream and then | ||
| 288 | // redirects to the registered sign-out URL. Without this hop, the | ||
| 289 | // hosted session survives and the next "Sign in" silently | ||
| 290 | // re-authenticates (issue #1445). Fail-open when the cookie won't | ||
| 291 | // unseal — there is then nothing to end upstream, and local sign-out | ||
| 292 | // must still complete, so fall back to "/". | ||
| 293 | const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | ||
| 294 | const logoutUrl = sealedSession | ||
| 295 | ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) | ||
| 296 | : null; | ||
| 297 | |||
| 298 | const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); | ||
| 299 | |||
| 300 | // Drop only what this browser actually presented. Both cookies are | ||
| 301 | // SameSite=Lax, so a cross-site form POST carries neither — it gets | ||
| 302 | // the bare redirect and cannot be used to sign anyone out. | ||
| 303 | if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; | ||
| 304 | |||
| 305 | // The auth-hint travels with the session: leaving it behind would | ||
| 306 | // make the next page load optimistically paint the app shell for a | ||
| 307 | // signed-out browser. | ||
| 308 | return deleteResponseCookie( | ||
| 309 | deleteResponseCookie(response, "wos-session"), | ||
| 310 | AUTH_HINT_COOKIE, | ||
| 311 | ); | ||
| 312 | }), | ||
| 313 | ) | ||
| 262 | // CLI device-login discovery. The WorkOS device endpoints live on the | 314 | // CLI device-login discovery. The WorkOS device endpoints live on the |
| 263 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, | 315 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, |
| 264 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with | 316 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with |
| @@ -304,17 +356,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 39 unmodified lines | |||
| 304 | }; | 356 | }; |
| 305 | }), | 357 | }), |
| 306 | ) | 358 | ) |
| 307 | .handleRaw("logout", () => | ||
| 308 | Effect.succeed( | ||
| 309 | // The auth-hint travels with the session: leaving it behind would | ||
| 310 | // make the next page load optimistically paint the app shell for a | ||
| 311 | // signed-out browser. | ||
| 312 | deleteResponseCookie( | ||
| 313 | deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"), | ||
| 314 | AUTH_HINT_COOKIE, | ||
| 315 | ), | ||
| 316 | ), | ||
| 317 | ) | ||
| 318 | .handle("organizations", () => | 359 | .handle("organizations", () => |
| 319 | Effect.gen(function* () { | 360 | Effect.gen(function* () { |
| 320 | const workos = yield* WorkOSClient; | 361 | const workos = yield* WorkOSClient; |
| @@ -375,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 54 unmodified lines | |||
| 375 | ), | 416 | ), |
| 376 | { concurrency: 3 }, | 417 | { concurrency: 3 }, |
| 377 | ).pipe( | 418 | ).pipe( |
| 378 | Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), | 419 | // Any Autumn failure here (outage or missing customer) leaves the |
| 420 | // paid/free split unknown, and the limit must fail closed. | ||
| 421 | Effect.mapError(() => new WorkOSError()), | ||
| 379 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), | 422 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), |
| 380 | ); | 423 | ); |
| 381 | 424 | ||
| @@ -387,10 +430,28 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines | |||
| 387 | const org = yield* workos.createOrganization(name); | 430 | const org = yield* workos.createOrganization(name); |
| 388 | yield* workos.createMembership(org.id, session.accountId, "admin"); | 431 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 389 | // `upsertOrganization` mints the slug at insert — no separate heal step. | 432 | // `upsertOrganization` mints the slug at insert — no separate heal step. |
| 390 | const mirrored = yield* users.use((s) => | 433 | const mirrored = yield* users.use("upsertOrganization", (s) => |
| 391 | s.upsertOrganization({ id: org.id, name: org.name }), | 434 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 392 | ); | 435 | ); |
| 393 | 436 | ||
| 437 | // Provision the org's billing customer while we're the ones creating | ||
| 438 | // the org. Without this the first billing call an org ever makes is a | ||
| 439 | // non-creating one (balance check / usage track), which 404s and keeps | ||
| 440 | // 404ing — unlimited unbilled executions. Non-fatal: a billing blip | ||
| 441 | // must not block signup, and the billing seam heals a customer that | ||
| 442 | // is still missing later. | ||
| 443 | yield* autumn.ensureCustomer(org.id).pipe( | ||
| 444 | Effect.catch((error) => | ||
| 445 | Effect.gen(function* () { | ||
| 446 | yield* Effect.logWarning( | ||
| 447 | "createOrganization: could not provision the Autumn customer", | ||
| 448 | { organizationId: org.id, error }, | ||
| 449 | ); | ||
| 450 | yield* captureCauseEffect(error); | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | |||
| 394 | // Try to attach the new org to the current session. This can fail | 455 | // Try to attach the new org to the current session. This can fail |
| 395 | // (or silently return a session still scoped to the old org) when | 456 | // (or silently return a session still scoped to the old org) when |
| 396 | // the caller's current session is stale — most commonly after the | 457 | // the caller's current session is stale — most commonly after the |
| @@ -441,7 +502,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 44 unmodified lines | |||
| 441 | 502 | ||
| 442 | // The typed confirmation must match the org's current name — the same | 503 | // The typed confirmation must match the org's current name — the same |
| 443 | // label the settings page shows. Trimmed on both sides. | 504 | // label the settings page shows. Trimmed on both sides. |
| 444 | const org = yield* users.use((s) => s.getOrganization(organizationId)); | 505 | const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); |
| 445 | if (!org || payload.confirmName.trim() !== org.name.trim()) { | 506 | if (!org || payload.confirmName.trim() !== org.name.trim()) { |
| 446 | return yield* new OrganizationDeletionForbidden(); | 507 | return yield* new OrganizationDeletionForbidden(); |
| 447 | } | 508 | } |
| @@ -459,7 +520,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 11 unmodified lines | |||
| 459 | // everyone (unreachable) but its secrets/tenant rows linger orphaned — | 520 | // everyone (unreachable) but its secrets/tenant rows linger orphaned — |
| 460 | // alert loudly so that window gets swept, then surface the failure. | 521 | // alert loudly so that window gets swept, then surface the failure. |
| 461 | yield* users | 522 | yield* users |
| 462 | .use((s) => s.deleteOrganizationCascade(organizationId)) | 523 | .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) |
| 463 | .pipe( | 524 | .pipe( |
| 464 | Effect.tapError((error) => | 525 | Effect.tapError((error) => |
| 465 | Effect.logError( | 526 | Effect.logError( |
| @@ -475,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines | |||
| 475 | yield* autumn | 536 | yield* autumn |
| 476 | .use((client) => client.customers.delete({ customerId: organizationId })) | 537 | .use((client) => client.customers.delete({ customerId: organizationId })) |
| 477 | .pipe( | 538 | .pipe( |
| 478 | Effect.catchTag("AutumnError", (error) => | 539 | // Includes the "customer never existed" answer: nothing to cancel |
| 540 | // is a fine outcome for a deleted org, and it is still worth a line. | ||
| 541 | Effect.catch((error) => | ||
| 479 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { | 542 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { |
| 480 | organizationId, | 543 | organizationId, |
| 481 | error, | 544 | error, |
| @@ -560,7 +623,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 78 unmodified lines | |||
| 560 | // Mirror the org locally so domain tables can FK against it; the | 623 | // Mirror the org locally so domain tables can FK against it; the |
| 561 | // upsert mints the slug at insert — no separate heal step. | 624 | // upsert mints the slug at insert — no separate heal step. |
| 562 | const org = yield* workos.getOrganization(invitation.organizationId); | 625 | const org = yield* workos.getOrganization(invitation.organizationId); |
| 563 | const mirrored = yield* users.use((s) => | 626 | const mirrored = yield* users.use("upsertOrganization", (s) => |
| 564 | s.upsertOrganization({ id: org.id, name: org.name }), | 627 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 565 | ); | 628 | ); |
| 566 | 629 | ||
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type KeyLike, | 9 | type KeyLike, |
| 10 | } from "jose"; | 10 | } from "jose"; |
| 11 | 11 | ||
| 12 | import { createCachedRemoteJWKSet } from "./jwks-cache"; | 12 | import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; |
| 13 | 13 | ||
| 14 | const issuer = "https://test-authkit.example.com"; | 14 | const issuer = "https://test-authkit.example.com"; |
| 15 | const audience = "client_test_fixture"; | 15 | const audience = "client_test_fixture"; |
| @@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { · 49 unmodified lines | |||
| 65 | }; | 65 | }; |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | interface StoreHarness extends JwksStore { | ||
| 69 | readonly seed: (stored: StoredJwks) => void; | ||
| 70 | readonly reads: () => number; | ||
| 71 | readonly writes: () => number; | ||
| 72 | } | ||
| 73 | |||
| 74 | /** Stands in for the Workers Cache API: shared across "isolates", in memory. */ | ||
| 75 | const makeStoreHarness = (): StoreHarness => { | ||
| 76 | const entries = new Map<string, StoredJwks>(); | ||
| 77 | let reads = 0; | ||
| 78 | let writes = 0; | ||
| 79 | return { | ||
| 80 | get: async (url) => { | ||
| 81 | reads++; | ||
| 82 | return entries.get(url.toString()) ?? null; | ||
| 83 | }, | ||
| 84 | put: async (url, stored) => { | ||
| 85 | writes++; | ||
| 86 | entries.set(url.toString(), stored); | ||
| 87 | }, | ||
| 88 | seed: (stored) => { | ||
| 89 | entries.set(jwksUrl.toString(), stored); | ||
| 90 | }, | ||
| 91 | reads: () => reads, | ||
| 92 | writes: () => writes, | ||
| 93 | }; | ||
| 94 | }; | ||
| 95 | |||
| 96 | /** A fetch that always fails, standing in for a slow/down key server. */ | ||
| 97 | const failingFetch: typeof globalThis.fetch = async () => { | ||
| 98 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting | ||
| 99 | throw new Error("JWKS endpoint unreachable"); | ||
| 100 | }; | ||
| 101 | |||
| 68 | describe("createCachedRemoteJWKSet", () => { | 102 | describe("createCachedRemoteJWKSet", () => { |
| 69 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { | 103 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { |
| 70 | const kp = await generateRotatableKeypair("k1"); | 104 | const kp = await generateRotatableKeypair("k1"); |
| @@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines | |||
| 168 | await jwtVerify(t1, jwks, { issuer, audience }); | 202 | await jwtVerify(t1, jwks, { issuer, audience }); |
| 169 | expect(harness.callCount()).toBe(2); | 203 | expect(harness.callCount()).toBe(2); |
| 170 | }); | 204 | }); |
| 205 | |||
| 206 | // ------------------------------------------------------------------------- | ||
| 207 | // Cross-isolate store — the cold-isolate path that caused the 2026-08-18 | ||
| 208 | // 500s: no module-scope entry, so every verify paid an upstream fetch. | ||
| 209 | // ------------------------------------------------------------------------- | ||
| 210 | |||
| 211 | it("a cold isolate serves from the store instead of going upstream", async () => { | ||
| 212 | const kp = await generateRotatableKeypair("k1"); | ||
| 213 | const store = makeStoreHarness(); | ||
| 214 | |||
| 215 | // First isolate: cold everywhere, so it fetches and populates the store. | ||
| 216 | const warm = makeFetchHarness([kp.publicJwk]); | ||
| 217 | const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); | ||
| 218 | const t1 = await sign(kp); | ||
| 219 | await jwtVerify(t1, first, { issuer, audience }); | ||
| 220 | expect(warm.callCount()).toBe(1); | ||
| 221 | expect(store.writes()).toBe(1); | ||
| 222 | |||
| 223 | // A brand-new isolate (fresh module scope). If it reaches upstream at all | ||
| 224 | // the fetch fails, so verifying proves the store answered. | ||
| 225 | const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); | ||
| 226 | const { payload } = await jwtVerify(t1, second, { issuer, audience }); | ||
| 227 | expect(payload.sub).toBe("user_test"); | ||
| 228 | expect(store.reads()).toBeGreaterThan(0); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("keeps serving the last good keys when the key server is down", async () => { | ||
| 232 | const kp = await generateRotatableKeypair("k1"); | ||
| 233 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 234 | let upstreamUp = true; | ||
| 235 | const flaky: typeof globalThis.fetch = (...args) => | ||
| 236 | upstreamUp ? harness.fetch(...args) : failingFetch(...args); | ||
| 237 | |||
| 238 | // ttl short enough that the next call is past it, stale window generous. | ||
| 239 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 240 | fetch: flaky, | ||
| 241 | store: null, | ||
| 242 | ttlMs: 10, | ||
| 243 | staleMaxMs: 60_000, | ||
| 244 | }); | ||
| 245 | |||
| 246 | const token = await sign(kp); | ||
| 247 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 248 | |||
| 249 | upstreamUp = false; | ||
| 250 | await new Promise((r) => setTimeout(r, 20)); | ||
| 251 | |||
| 252 | // Past the TTL with a dead upstream: the cached keys still verify. | ||
| 253 | const { payload } = await jwtVerify(token, jwks, { issuer, audience }); | ||
| 254 | expect(payload.sub).toBe("user_test"); | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("stops serving stale keys once the stale window closes", async () => { | ||
| 258 | const kp = await generateRotatableKeypair("k1"); | ||
| 259 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 260 | let upstreamUp = true; | ||
| 261 | const flaky: typeof globalThis.fetch = (...args) => | ||
| 262 | upstreamUp ? harness.fetch(...args) : failingFetch(...args); | ||
| 263 | |||
| 264 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 265 | fetch: flaky, | ||
| 266 | store: null, | ||
| 267 | ttlMs: 5, | ||
| 268 | staleMaxMs: 10, | ||
| 269 | }); | ||
| 270 | |||
| 271 | const token = await sign(kp); | ||
| 272 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 273 | |||
| 274 | upstreamUp = false; | ||
| 275 | await new Promise((r) => setTimeout(r, 30)); | ||
| 276 | |||
| 277 | // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't | ||
| 278 | // silently honour a key set we can no longer confirm. | ||
| 279 | await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow(); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => { | ||
| 283 | const kp = await generateRotatableKeypair("k1"); | ||
| 284 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 285 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 286 | fetch: harness.fetch, | ||
| 287 | store: null, | ||
| 288 | ttlMs: 10, | ||
| 289 | staleMaxMs: 60_000, | ||
| 290 | }); | ||
| 291 | |||
| 292 | const token = await sign(kp); | ||
| 293 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 294 | expect(jwks.inspect().blockingFetchCount).toBe(1); | ||
| 295 | |||
| 296 | await new Promise((r) => setTimeout(r, 20)); | ||
| 297 | |||
| 298 | // Past the TTL: served from the stale entry, revalidated behind it. The | ||
| 299 | // caller waited on nothing, so latency must not be attributed to it. | ||
| 300 | const before = jwks.inspect(); | ||
| 301 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 302 | const after = jwks.inspect(); | ||
| 303 | |||
| 304 | expect(after.blockingFetchCount).toBe(before.blockingFetchCount); | ||
| 305 | expect(after.fetchCount).toBeGreaterThan(before.fetchCount); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it("records a cold-isolate store read as a store hit, not a fetch", async () => { | ||
| 309 | const kp = await generateRotatableKeypair("k1"); | ||
| 310 | const store = makeStoreHarness(); | ||
| 311 | const warm = makeFetchHarness([kp.publicJwk]); | ||
| 312 | |||
| 313 | const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); | ||
| 314 | const token = await sign(kp); | ||
| 315 | await jwtVerify(token, first, { issuer, audience }); | ||
| 316 | |||
| 317 | const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); | ||
| 318 | await jwtVerify(token, second, { issuer, audience }); | ||
| 319 | |||
| 320 | const stats = second.inspect(); | ||
| 321 | expect(stats.storeHitCount).toBe(1); | ||
| 322 | expect(stats.blockingFetchCount).toBe(0); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("does not block a request on revalidation once keys are cached", async () => { | ||
| 326 | const kp = await generateRotatableKeypair("k1"); | ||
| 327 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 328 | let hang = false; | ||
| 329 | const slowAfterFirst: typeof globalThis.fetch = async (...args) => { | ||
| 330 | if (hang) await new Promise((r) => setTimeout(r, 5_000)); | ||
| 331 | return harness.fetch(...args); | ||
| 332 | }; | ||
| 333 | |||
| 334 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 335 | fetch: slowAfterFirst, | ||
| 336 | store: null, | ||
| 337 | ttlMs: 10, | ||
| 338 | staleMaxMs: 60_000, | ||
| 339 | }); | ||
| 340 | |||
| 341 | const token = await sign(kp); | ||
| 342 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 343 | |||
| 344 | hang = true; | ||
| 345 | await new Promise((r) => setTimeout(r, 20)); | ||
| 346 | |||
| 347 | // The revalidation behind this call hangs for 5s; the verify must not. | ||
| 348 | const startedAt = Date.now(); | ||
| 349 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 350 | expect(Date.now() - startedAt).toBeLessThan(1_000); | ||
| 351 | }); | ||
| 171 | }); | 352 | }); |
| @@ -1,5 +1,5 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // In-memory JWKS cache for MCP JWT verification. | 2 | // JWKS cache for JWT verification (session + MCP auth). |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // | 4 | // |
| 5 | // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s | 5 | // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s |
| @@ -7,14 +7,32 @@ · 1 unmodified line | |||
| 7 | // fetches per hour because each new isolate starts cold. Production p99 for | 7 | // fetches per hour because each new isolate starts cold. Production p99 for |
| 8 | // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. | 8 | // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. |
| 9 | // | 9 | // |
| 10 | // This module offers a drop-in `createCachedRemoteJWKSet` that: | 10 | // Module-scope memory alone only helps while an isolate stays warm. When |
| 11 | // `workos.session.local_verify` started missing ~92% of the time (2026-08-18), | ||
| 12 | // every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s | ||
| 13 | // request timeout, turning a slow key-server into `AbortError` → 500 on every | ||
| 14 | // authenticated API route and 503 on `/mcp`. A transient key-server blip must | ||
| 15 | // never read as an auth failure, and a cold isolate must not have to go | ||
| 16 | // upstream at all. | ||
| 11 | // | 17 | // |
| 12 | // * Caches the JSON Web Key Set in module-scope memory for a configurable | 18 | // So this module layers three caches, fastest first: |
| 13 | // TTL (default 1 hour). | 19 | // |
| 14 | // * Single-flights concurrent fetches so a stampede of verifies during a | 20 | // 1. Module-scope memory — free, but dies with the isolate. |
| 15 | // cache miss only fires one upstream request. | 21 | // 2. A cross-isolate store (the Workers Cache API by default) — colo-local, |
| 16 | // * Force-refreshes once when verification fails with a cached key, so | 22 | // survives isolate recycling, so a cold isolate reads keys in ~1ms |
| 17 | // genuine key rotation isn't blocked by the TTL. | 23 | // instead of paying an upstream round trip. |
| 24 | // 3. The upstream JWKS endpoint. | ||
| 25 | // | ||
| 26 | // On top of that it is stale-while-revalidate: once past `ttlMs` a usable key | ||
| 27 | // set is served immediately and refreshed in the background, and if a refresh | ||
| 28 | // fails we keep serving the last good keys until `staleMaxMs`. Only a fully | ||
| 29 | // cold path (no memory, no store) ever blocks on the network. | ||
| 30 | // | ||
| 31 | // Serving stale keys is safe in a way that serving a stale *token* would not | ||
| 32 | // be: key sets rotate on the order of days, tokens are still signature- and | ||
| 33 | // expiry-checked against them, and a token whose `kid` is absent forces a real | ||
| 34 | // refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a | ||
| 35 | // retired key can still be honoured. | ||
| 18 | // | 36 | // |
| 19 | // The returned function is a `JWTVerifyGetKey` and slots directly into | 37 | // The returned function is a `JWTVerifyGetKey` and slots directly into |
| 20 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can | 38 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can |
| @@ -32,6 +50,21 @@ import { · 11 unmodified lines | |||
| 32 | import { Schema } from "effect"; | 50 | import { Schema } from "effect"; |
| 33 | import { JWKSNoMatchingKey } from "jose/errors"; | 51 | import { JWKSNoMatchingKey } from "jose/errors"; |
| 34 | 52 | ||
| 53 | /** | ||
| 54 | * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and | ||
| 55 | * non-Workers hosts pass their own, or `null` to stay memory-only. | ||
| 56 | */ | ||
| 57 | export interface JwksStore { | ||
| 58 | readonly get: (url: URL) => Promise<StoredJwks | null>; | ||
| 59 | readonly put: (url: URL, stored: StoredJwks) => Promise<void>; | ||
| 60 | } | ||
| 61 | |||
| 62 | export interface StoredJwks { | ||
| 63 | readonly jwks: JSONWebKeySet; | ||
| 64 | /** Epoch ms of the upstream fetch these keys came from. */ | ||
| 65 | readonly fetchedAt: number; | ||
| 66 | } | ||
| 67 | |||
| 35 | export interface CachedRemoteJWKSetOptions { | 68 | export interface CachedRemoteJWKSetOptions { |
| 36 | /** | 69 | /** |
| 37 | * How long a successful fetch is considered fresh. Defaults to 1 hour — | 70 | * How long a successful fetch is considered fresh. Defaults to 1 hour — |
| @@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line | |||
| 39 | * failure handles unscheduled rotations. | 72 | * failure handles unscheduled rotations. |
| 40 | */ | 73 | */ |
| 41 | readonly ttlMs?: number; | 74 | readonly ttlMs?: number; |
| 75 | /** | ||
| 76 | * How long past `ttlMs` a key set may still be served while refreshes are | ||
| 77 | * failing. Defaults to 24h — long enough to ride out a key-server outage, | ||
| 78 | * short enough to bound how long a retired key stays honoured. | ||
| 79 | */ | ||
| 80 | readonly staleMaxMs?: number; | ||
| 42 | /** Override the fetch implementation for tests. */ | 81 | /** Override the fetch implementation for tests. */ |
| 43 | readonly fetch?: typeof globalThis.fetch; | 82 | readonly fetch?: typeof globalThis.fetch; |
| 44 | /** HTTP request timeout. Defaults to 5s, matching jose. */ | 83 | /** HTTP request timeout. Defaults to 5s, matching jose. */ |
| 45 | readonly timeoutMs?: number; | 84 | readonly timeoutMs?: number; |
| 85 | /** | ||
| 86 | * Cross-isolate store. Defaults to the Workers Cache API when available, | ||
| 87 | * `null` disables the layer (memory-only). | ||
| 88 | */ | ||
| 89 | readonly store?: JwksStore | null; | ||
| 46 | } | 90 | } |
| 47 | 91 | ||
| 48 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { | 92 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { |
| 49 | /** Drop the cached JWKS so the next call refetches. */ | 93 | /** Drop the cached JWKS so the next call refetches. */ |
| 50 | readonly forceRefresh: () => void; | 94 | readonly forceRefresh: () => void; |
| 51 | /** Inspect the current cache state (testing/diagnostics). */ | 95 | /** |
| 52 | readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean }; | 96 | * Inspect the current cache state (testing/diagnostics/span annotation). |
| 97 | * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver | ||
| 98 | * instance; callers snapshot them around a verify to tell a cache hit from | ||
| 99 | * a live upstream fetch (the Aug 2026 latency regression was exactly this | ||
| 100 | * cache silently missing on ~92% of verifies, invisible in traces). | ||
| 101 | * | ||
| 102 | * `blockingFetchCount` counts only fetches a verify actually WAITED on. | ||
| 103 | * Under stale-while-revalidate `fetchCount` also moves for background | ||
| 104 | * revalidation the caller never paid for, so latency attribution wants | ||
| 105 | * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered | ||
| 106 | * by the cross-isolate store instead of going upstream. | ||
| 107 | */ | ||
| 108 | readonly inspect: () => { | ||
| 109 | fetchedAt: number | null; | ||
| 110 | hasJwks: boolean; | ||
| 111 | fetchCount: number; | ||
| 112 | fetchFailureCount: number; | ||
| 113 | blockingFetchCount: number; | ||
| 114 | storeHitCount: number; | ||
| 115 | lastFetchDurationMs: number | null; | ||
| 116 | /** Wall-clock of the last cross-isolate store read (I/O). */ | ||
| 117 | lastStoreReadMs: number | null; | ||
| 118 | /** Wall-clock of the last key resolution — WebCrypto `importKey`. */ | ||
| 119 | lastResolveMs: number | null; | ||
| 120 | }; | ||
| 53 | } | 121 | } |
| 54 | 122 | ||
| 55 | const DEFAULT_TTL_MS = 60 * 60 * 1000; | 123 | const DEFAULT_TTL_MS = 60 * 60 * 1000; |
| 124 | const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000; | ||
| 56 | const DEFAULT_TIMEOUT_MS = 5000; | 125 | const DEFAULT_TIMEOUT_MS = 5000; |
| 57 | 126 | ||
| 58 | const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); | 127 | const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); |
| @@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines | |||
| 73 | resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; | 142 | resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; |
| 74 | } | 143 | } |
| 75 | 144 | ||
| 145 | const entryFrom = (stored: StoredJwks): CacheEntry => ({ | ||
| 146 | jwks: stored.jwks, | ||
| 147 | fetchedAt: stored.fetchedAt, | ||
| 148 | resolver: createLocalJWKSet(stored.jwks), | ||
| 149 | }); | ||
| 150 | |||
| 151 | /** | ||
| 152 | * Cache upkeep (store writes, background revalidation) is best effort: it must | ||
| 153 | * never fail or delay the verify that happened to trigger it. | ||
| 154 | */ | ||
| 155 | const ignoreFailure = async (work: Promise<unknown>): Promise<void> => { | ||
| 156 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure | ||
| 157 | try { | ||
| 158 | await work; | ||
| 159 | } catch { | ||
| 160 | // Deliberately swallowed — the caller already has usable keys, or will | ||
| 161 | // take the upstream path on its next miss. | ||
| 162 | } | ||
| 163 | }; | ||
| 164 | |||
| 76 | const fetchJwksOnce = async ( | 165 | const fetchJwksOnce = async ( |
| 77 | url: URL, | 166 | url: URL, |
| 78 | fetchImpl: typeof globalThis.fetch, | 167 | fetchImpl: typeof globalThis.fetch, |
| @@ -107,17 +196,74 @@ const fetchJwksOnce = async ( · 28 unmodified lines | |||
| 107 | } | 196 | } |
| 108 | }; | 197 | }; |
| 109 | 198 | ||
| 199 | // --------------------------------------------------------------------------- | ||
| 200 | // Workers Cache API store | ||
| 201 | // --------------------------------------------------------------------------- | ||
| 202 | // | ||
| 203 | // Keyed by the JWKS URL itself. We store our own Response rather than the | ||
| 204 | // upstream one so the body is already-validated JSON and the cache headers are | ||
| 205 | // ours: `max-age` covers the stale window, because an entry past `ttlMs` is | ||
| 206 | // still useful to us as a stale fallback. | ||
| 207 | |||
| 208 | const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at"; | ||
| 209 | |||
| 210 | /** `caches.default` is a Workers extension the standard lib type omits. */ | ||
| 211 | type WorkersCacheStorage = CacheStorage & { readonly default?: Cache }; | ||
| 212 | |||
| 213 | const workersCacheStore = (staleMaxMs: number): JwksStore | null => { | ||
| 214 | if (typeof caches === "undefined") return null; | ||
| 215 | const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null; | ||
| 216 | |||
| 217 | return { | ||
| 218 | get: async (url) => { | ||
| 219 | const cache = open(); | ||
| 220 | if (!cache) return null; | ||
| 221 | const hit = await cache.match(url.toString()); | ||
| 222 | if (!hit) return null; | ||
| 223 | const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT); | ||
| 224 | const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader); | ||
| 225 | if (!Number.isFinite(fetchedAt)) return null; | ||
| 226 | const body = await hit.json(); | ||
| 227 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify | ||
| 228 | try { | ||
| 229 | await decodeJsonWebKeySetPayload(body); | ||
| 230 | } catch { | ||
| 231 | return null; | ||
| 232 | } | ||
| 233 | return { jwks: body as JSONWebKeySet, fetchedAt }; | ||
| 234 | }, | ||
| 235 | put: async (url, stored) => { | ||
| 236 | const cache = open(); | ||
| 237 | if (!cache) return; | ||
| 238 | const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000)); | ||
| 239 | await cache.put( | ||
| 240 | url.toString(), | ||
| 241 | new Response(JSON.stringify(stored.jwks), { | ||
| 242 | status: 200, | ||
| 243 | headers: { | ||
| 244 | "content-type": "application/json", | ||
| 245 | "cache-control": `max-age=${maxAgeSeconds}`, | ||
| 246 | [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt), | ||
| 247 | }, | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | }, | ||
| 251 | }; | ||
| 252 | }; | ||
| 253 | |||
| 110 | /** | 254 | /** |
| 111 | * Creates a cached, single-flight, force-refreshable JWKS resolver compatible | 255 | * Creates a cached, single-flight, force-refreshable JWKS resolver compatible |
| 112 | * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the | 256 | * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the |
| 113 | * MCP auth path — see module header for why we don't just use jose's built-in. | 257 | * auth paths — see module header for why we don't just use jose's built-in. |
| 114 | */ | 258 | */ |
| 115 | export const createCachedRemoteJWKSet = ( | 259 | export const createCachedRemoteJWKSet = ( |
| 116 | url: URL, | 260 | url: URL, |
| 117 | options: CachedRemoteJWKSetOptions = {}, | 261 | options: CachedRemoteJWKSetOptions = {}, |
| 118 | ): CachedRemoteJWKSet => { | 262 | ): CachedRemoteJWKSet => { |
| 119 | const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; | 263 | const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; |
| 264 | const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs); | ||
| 120 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | 265 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 266 | const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store; | ||
| 121 | // Capture the fetch impl lazily so consumers can swap globalThis.fetch | 267 | // Capture the fetch impl lazily so consumers can swap globalThis.fetch |
| 122 | // (tests do this) without us snapshotting a stale reference. | 268 | // (tests do this) without us snapshotting a stale reference. |
| 123 | const fetchImpl = (): typeof globalThis.fetch => | 269 | const fetchImpl = (): typeof globalThis.fetch => |
| @@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line | |||
| 125 | 271 | ||
| 126 | let entry: CacheEntry | null = null; | 272 | let entry: CacheEntry | null = null; |
| 127 | let inflight: Promise<CacheEntry> | null = null; | 273 | let inflight: Promise<CacheEntry> | null = null; |
| 274 | let fetchCount = 0; | ||
| 275 | let fetchFailureCount = 0; | ||
| 276 | let blockingFetchCount = 0; | ||
| 277 | let storeHitCount = 0; | ||
| 278 | let lastFetchDurationMs: number | null = null; | ||
| 279 | let lastStoreReadMs: number | null = null; | ||
| 280 | let lastResolveMs: number | null = null; | ||
| 281 | |||
| 282 | const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs; | ||
| 283 | const isUsable = (candidate: CacheEntry): boolean => | ||
| 284 | Date.now() - candidate.fetchedAt < staleMaxMs; | ||
| 128 | 285 | ||
| 129 | const refresh = (): Promise<CacheEntry> => { | 286 | const refresh = (): Promise<CacheEntry> => { |
| 130 | if (inflight) return inflight; | 287 | if (inflight) return inflight; |
| 288 | const startedAt = Date.now(); | ||
| 289 | fetchCount += 1; | ||
| 131 | inflight = (async () => { | 290 | inflight = (async () => { |
| 132 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); | 291 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 133 | const next: CacheEntry = { | 292 | const next = entryFrom({ jwks, fetchedAt: Date.now() }); |
| 134 | jwks, | ||
| 135 | fetchedAt: Date.now(), | ||
| 136 | resolver: createLocalJWKSet(jwks), | ||
| 137 | }; | ||
| 138 | entry = next; | 293 | entry = next; |
| 294 | if (store) { | ||
| 295 | await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt })); | ||
| 296 | } | ||
| 139 | return next; | 297 | return next; |
| 140 | })().finally(() => { | 298 | })() |
| 141 | inflight = null; | 299 | .then( |
| 142 | }); | 300 | (next) => next, |
| 301 | (error: unknown) => { | ||
| 302 | fetchFailureCount += 1; | ||
| 303 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose | ||
| 304 | throw error; | ||
| 305 | }, | ||
| 306 | ) | ||
| 307 | .finally(() => { | ||
| 308 | lastFetchDurationMs = Date.now() - startedAt; | ||
| 309 | inflight = null; | ||
| 310 | }); | ||
| 143 | return inflight; | 311 | return inflight; |
| 144 | }; | 312 | }; |
| 145 | 313 | ||
| 146 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | 314 | /** Fire-and-forget revalidation behind a stale hit. */ |
| 147 | if (forceRefresh) return refresh(); | 315 | const refreshInBackground = (): void => { |
| 148 | if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; | 316 | void ignoreFailure(refresh()); |
| 317 | }; | ||
| 318 | |||
| 319 | const loadFromStore = async (): Promise<CacheEntry | null> => { | ||
| 320 | if (!store) return null; | ||
| 321 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch | ||
| 322 | try { | ||
| 323 | const storeReadStartedAt = Date.now(); | ||
| 324 | const stored = await store.get(url); | ||
| 325 | lastStoreReadMs = Date.now() - storeReadStartedAt; | ||
| 326 | if (!stored) return null; | ||
| 327 | const candidate = entryFrom(stored); | ||
| 328 | if (!isUsable(candidate)) return null; | ||
| 329 | entry = candidate; | ||
| 330 | storeHitCount += 1; | ||
| 331 | return candidate; | ||
| 332 | } catch { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | }; | ||
| 336 | |||
| 337 | /** A refresh the caller waits on — the only kind that costs it latency. */ | ||
| 338 | const refreshBlocking = (): Promise<CacheEntry> => { | ||
| 339 | blockingFetchCount += 1; | ||
| 149 | return refresh(); | 340 | return refresh(); |
| 150 | }; | 341 | }; |
| 151 | 342 | ||
| 343 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | ||
| 344 | if (forceRefresh) return refreshBlocking(); | ||
| 345 | if (entry && isFresh(entry)) return entry; | ||
| 346 | |||
| 347 | // Memory is stale but still usable: serve it now, revalidate behind it. | ||
| 348 | if (entry && isUsable(entry)) { | ||
| 349 | refreshInBackground(); | ||
| 350 | return entry; | ||
| 351 | } | ||
| 352 | |||
| 353 | // Cold isolate — the L2 store saves us the upstream round trip. | ||
| 354 | const stored = await loadFromStore(); | ||
| 355 | if (stored) { | ||
| 356 | if (!isFresh(stored)) refreshInBackground(); | ||
| 357 | return stored; | ||
| 358 | } | ||
| 359 | |||
| 360 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify | ||
| 361 | try { | ||
| 362 | return await refreshBlocking(); | ||
| 363 | } catch (error) { | ||
| 364 | // Upstream is slow or down. Last good keys beat failing every request. | ||
| 365 | if (entry && isUsable(entry)) return entry; | ||
| 366 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer | ||
| 367 | throw error; | ||
| 368 | } | ||
| 369 | }; | ||
| 370 | |||
| 152 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { | 371 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| 153 | const current = await ensureFresh(false); | 372 | const current = await ensureFresh(false); |
| 154 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures | 373 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures |
| 155 | try { | 374 | try { |
| 156 | return await current.resolver(protectedHeader, token); | 375 | const resolveStartedAt = Date.now(); |
| 376 | const key = await current.resolver(protectedHeader, token); | ||
| 377 | lastResolveMs = Date.now() - resolveStartedAt; | ||
| 378 | return key; | ||
| 157 | } catch (error) { | 379 | } catch (error) { |
| 158 | // Likely cause: keys rotated upstream after our TTL window started. | 380 | // Likely cause: keys rotated upstream after our TTL window started, or |
| 159 | // Refetch once and try again. Anything still failing bubbles up so | 381 | // we answered from a stale entry. Refetch once and try again. Anything |
| 160 | // jose can classify it (we do not silently swallow real failures). | 382 | // still failing bubbles up so jose can classify it (we do not silently |
| 383 | // swallow real failures). | ||
| 161 | if (!isJwksNoMatchingKey(error)) { | 384 | if (!isJwksNoMatchingKey(error)) { |
| 162 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection | 385 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection |
| 163 | throw error; | 386 | throw error; |
| @@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines | |||
| 177 | value: () => ({ | 400 | value: () => ({ |
| 178 | fetchedAt: entry?.fetchedAt ?? null, | 401 | fetchedAt: entry?.fetchedAt ?? null, |
| 179 | hasJwks: entry !== null, | 402 | hasJwks: entry !== null, |
| 403 | fetchCount, | ||
| 404 | fetchFailureCount, | ||
| 405 | blockingFetchCount, | ||
| 406 | storeHitCount, | ||
| 407 | lastFetchDurationMs, | ||
| 408 | lastStoreReadMs, | ||
| 409 | lastResolveMs, | ||
| 180 | }), | 410 | }), |
| 181 | }); | 411 | }); |
| 182 | return result; | 412 | return result; |
| @@ -0,0 +1,32 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Last-visited-org cookie — the browser's "which org was I working in" memory. | ||
| 3 | // | ||
| 4 | // Org switching is stateless by design (the URL slug scopes every request; see | ||
| 5 | // organization.ts), which leaves nothing that remembers the last org across | ||
| 6 | // entries: the sealed session still carries the org pinned at LOGIN time, so a | ||
| 7 | // bare URL (`executor.sh/`) would always canonicalize onto that first org. This | ||
| 8 | // cookie fills the gap: the client records the slug of the org it's verifiably | ||
| 9 | // viewing, and the two bare-entry deciders honor it — | ||
| 10 | // | ||
| 11 | // - the document auth gate redirects bare document paths onto it (doc-gate.ts) | ||
| 12 | // - the login callback prefers it when picking the org for a fresh session | ||
| 13 | // with a bare returnTo (handlers.ts) | ||
| 14 | // | ||
| 15 | // It is a PREFERENCE, never an authority: both readers re-check live membership | ||
| 16 | // through the same authorize path as any org selector, so a stale or forged | ||
| 17 | // value at worst falls back to today's behavior. Not HttpOnly — the client is | ||
| 18 | // the writer. Deliberately NOT cleared on logout: surviving the session is what | ||
| 19 | // lets the next login land on the last-used org. | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | export const LAST_ORG_COOKIE = "executor-last-org"; | ||
| 23 | |||
| 24 | /** Outlives the 7d session on purpose — it spans logins. */ | ||
| 25 | const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; | ||
| 26 | |||
| 27 | /** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */ | ||
| 28 | export const writeLastOrgCookie = (slug: string): void => { | ||
| 29 | document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${ | ||
| 30 | window.location.protocol === "https:" ? "; Secure" : "" | ||
| 31 | }`; | ||
| 32 | }; | ||
| @@ -0,0 +1,159 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyService } from "./api-keys"; | ||
| 5 | import { UserStoreService } from "./context"; | ||
| 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | ||
| 7 | import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; | ||
| 8 | |||
| 9 | // Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform | ||
| 10 | // view (tenant-wide, read-only, NO acting member) rather than to a member | ||
| 11 | // `Principal`. The product surfaces must reject it outright rather than | ||
| 12 | // inventing a subject for it — that is the property these tests pin. | ||
| 13 | |||
| 14 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | ||
| 15 | |||
| 16 | const stubApiKeys = Layer.succeed(ApiKeyService)({ | ||
| 17 | validate: (value: string) => { | ||
| 18 | if (value === "valid_org_key") { | ||
| 19 | return Effect.succeed({ | ||
| 20 | scope: "org" as const, | ||
| 21 | accountId: null, | ||
| 22 | organizationId: "org_123", | ||
| 23 | keyId: "api_key_org", | ||
| 24 | }); | ||
| 25 | } | ||
| 26 | if (value === "valid_user_key") { | ||
| 27 | return Effect.succeed({ | ||
| 28 | scope: "user" as const, | ||
| 29 | accountId: "user_123", | ||
| 30 | organizationId: "org_123", | ||
| 31 | keyId: "api_key_123", | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | return Effect.succeed(null); | ||
| 35 | }, | ||
| 36 | listUserKeys: () => Effect.succeed([]), | ||
| 37 | createUserKey: () => Effect.die("org api key test does not create API keys"), | ||
| 38 | revokeUserKey: () => Effect.void, | ||
| 39 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 40 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 41 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 42 | }); | ||
| 43 | |||
| 44 | const stubWorkOS = Layer.succeed( | ||
| 45 | WorkOSClient, | ||
| 46 | new Proxy({} as WorkOSClientService, { | ||
| 47 | get: (_target, prop) => { | ||
| 48 | if (prop === "listUserMemberships") { | ||
| 49 | return (userId: string) => | ||
| 50 | Effect.succeed({ | ||
| 51 | data: | ||
| 52 | userId === "user_123" | ||
| 53 | ? [{ userId, organizationId: "org_123", status: "active" }] | ||
| 54 | : [], | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | // An org key must NOT trigger a membership check — there is no user to | ||
| 58 | // check. Any such call dies here, which is the assertion. | ||
| 59 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 60 | }, | ||
| 61 | }), | ||
| 62 | ); | ||
| 63 | |||
| 64 | const stubUsers = Layer.succeed(UserStoreService)({ | ||
| 65 | use: (_op, fn) => | ||
| 66 | Effect.promise(() => | ||
| 67 | fn({ | ||
| 68 | ensureAccount: async (id: string) => ({ id, createdAt }), | ||
| 69 | getAccount: async (id: string) => ({ id, createdAt }), | ||
| 70 | upsertOrganization: async (org: { id: string; name: string }) => ({ | ||
| 71 | ...org, | ||
| 72 | slug: `org-slug-${org.id}`, | ||
| 73 | createdAt, | ||
| 74 | }), | ||
| 75 | getOrganization: async (id: string) => ({ | ||
| 76 | id, | ||
| 77 | name: `Org ${id}`, | ||
| 78 | slug: `org-slug-${id}`, | ||
| 79 | createdAt, | ||
| 80 | }), | ||
| 81 | getOrganizationBySlug: async (slug: string) => ({ | ||
| 82 | id: "org_by_slug", | ||
| 83 | name: `Org ${slug}`, | ||
| 84 | slug, | ||
| 85 | createdAt, | ||
| 86 | }), | ||
| 87 | deleteOrganizationCascade: async () => {}, | ||
| 88 | }), | ||
| 89 | ), | ||
| 90 | }); | ||
| 91 | |||
| 92 | const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers); | ||
| 93 | |||
| 94 | const bearer = (token: string) => | ||
| 95 | new Request("https://executor.test/api/tools", { | ||
| 96 | headers: { authorization: `Bearer ${token}` }, | ||
| 97 | }); | ||
| 98 | |||
| 99 | describe("org-level API keys", () => { | ||
| 100 | it.effect("resolve to the platform view with no acting member", () => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | ||
| 103 | |||
| 104 | expect(isPlatformAuth(auth)).toBe(true); | ||
| 105 | expect(auth).toEqual({ | ||
| 106 | kind: "platform", | ||
| 107 | organizationId: "org_123", | ||
| 108 | organizationName: "Org org_123", | ||
| 109 | organizationSlug: "org-slug-org_123", | ||
| 110 | keyId: "api_key_org", | ||
| 111 | }); | ||
| 112 | // No `accountId` anywhere in the shape: nothing downstream can bind a | ||
| 113 | // subject from it by accident. | ||
| 114 | expect(auth).not.toHaveProperty("accountId"); | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | |||
| 118 | it.effect("user keys still resolve to a bound member principal", () => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers)); | ||
| 121 | |||
| 122 | expect(isPlatformAuth(auth)).toBe(false); | ||
| 123 | expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" }); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("resolve on the product surface as a platform principal, never a subject", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | // THE security property of the api-key half, updated for platform reads: | ||
| 130 | // a product request carrying an org key resolves to the NEUTRAL platform | ||
| 131 | // shape — which the shared middleware routes to the subject-less, | ||
| 132 | // GET-only platform executor — and must never carry an accountId a | ||
| 133 | // handler could bind as an acting member. | ||
| 134 | const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe( | ||
| 135 | Effect.provide(layers), | ||
| 136 | ); | ||
| 137 | |||
| 138 | expect(principal).toEqual({ | ||
| 139 | kind: "platform", | ||
| 140 | organizationId: "org_123", | ||
| 141 | organizationName: "Org org_123", | ||
| 142 | organizationSlug: "org-slug-org_123", | ||
| 143 | keyId: "api_key_org", | ||
| 144 | }); | ||
| 145 | expect(principal).not.toHaveProperty("accountId"); | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("do not trigger a user membership check", () => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | // `authorizeOrganization` checks a USER's live membership; there is no | ||
| 152 | // user here. The WorkOS stub dies on any call other than the user path, | ||
| 153 | // so a clean resolution proves the org branch never took it. | ||
| 154 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | ||
| 155 | |||
| 156 | expect(isPlatformAuth(auth)).toBe(true); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | }); | ||
| @@ -0,0 +1,316 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; | ||
| 5 | import { ApiKeyManagementError } from "./errors"; | ||
| 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | ||
| 7 | |||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | // Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged | ||
| 10 | // credential that resolves to the read-only platform view (`/admin/*`). | ||
| 11 | // | ||
| 12 | // The decode boundary is what these pin. WorkOS returns an org-owned key with | ||
| 13 | // the ORG in `owner.id` and no member at all, which is a different shape from | ||
| 14 | // the user-owned keys the console's personal-key CRUD handles. The workspace | ||
| 15 | // api key is also workspace-WIDE, so a response naming a different org than the | ||
| 16 | // one asked for must not be surfaced as that org's key. | ||
| 17 | // | ||
| 18 | // NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes | ||
| 19 | // `owner.type: "user"` and exposes no org-key mint route, so this path cannot | ||
| 20 | // be exercised over the wire in e2e today. These stubs encode the REAL WorkOS | ||
| 21 | // API shape (`organizations.createOrganizationApiKey` / | ||
| 22 | // `listOrganizationApiKeys`, per the installed SDK's types). | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | const ORG = "org_123"; | ||
| 26 | |||
| 27 | const orgKeyResponse = (organizationId: string, id: string, name: string) => ({ | ||
| 28 | object: "api_key", | ||
| 29 | id, | ||
| 30 | owner: { type: "organization", id: organizationId }, | ||
| 31 | name, | ||
| 32 | obfuscatedValue: "sk_org…a1b2", | ||
| 33 | lastUsedAt: null, | ||
| 34 | permissions: [], | ||
| 35 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 37 | }); | ||
| 38 | |||
| 39 | const stubWorkOS = (overrides: Record<string, unknown> = {}) => | ||
| 40 | Layer.succeed( | ||
| 41 | WorkOSClient, | ||
| 42 | new Proxy({} as WorkOSClientService, { | ||
| 43 | get: (_target, prop) => { | ||
| 44 | const override = overrides[String(prop)]; | ||
| 45 | if (override) return override; | ||
| 46 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 47 | }, | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const serviceWith = (overrides: Record<string, unknown>) => | ||
| 52 | ApiKeyService.asEffect().pipe( | ||
| 53 | Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))), | ||
| 54 | ); | ||
| 55 | |||
| 56 | describe("organization API keys", () => { | ||
| 57 | it.effect("mints an org-owned key and returns the one-time secret", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const apiKeys = yield* serviceWith({ | ||
| 60 | createOrgApiKey: (params: { organizationId: string; name: string }) => | ||
| 61 | Effect.succeed({ | ||
| 62 | ...orgKeyResponse(params.organizationId, "key_org_1", params.name), | ||
| 63 | value: "sk_org_plaintext", | ||
| 64 | }), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }); | ||
| 68 | |||
| 69 | expect(created.id).toBe("key_org_1"); | ||
| 70 | expect(created.name).toBe("acme-backend"); | ||
| 71 | expect(created.value, "create returns the one-time plaintext secret").toBe( | ||
| 72 | "sk_org_plaintext", | ||
| 73 | ); | ||
| 74 | expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe( | ||
| 75 | created.value, | ||
| 76 | ); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | it.effect("lists the organization's own keys, masked", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const apiKeys = yield* serviceWith({ | ||
| 83 | listOrgApiKeys: (organizationId: string) => | ||
| 84 | Effect.succeed({ | ||
| 85 | object: "list", | ||
| 86 | data: [ | ||
| 87 | orgKeyResponse(organizationId, "key_org_1", "acme-backend"), | ||
| 88 | orgKeyResponse(organizationId, "key_org_2", "ci"), | ||
| 89 | ], | ||
| 90 | listMetadata: { before: null, after: null }, | ||
| 91 | }), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 95 | |||
| 96 | expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]); | ||
| 97 | expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain( | ||
| 98 | "sk_org_plaintext", | ||
| 99 | ); | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | it.effect("drops a key owned by a DIFFERENT organization", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | // The WorkOS workspace api key is workspace-wide, so a response naming | ||
| 106 | // another org must never be surfaced as this org's key. | ||
| 107 | const apiKeys = yield* serviceWith({ | ||
| 108 | listOrgApiKeys: () => | ||
| 109 | Effect.succeed({ | ||
| 110 | object: "list", | ||
| 111 | data: [ | ||
| 112 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 113 | orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 114 | ], | ||
| 115 | listMetadata: { before: null, after: null }, | ||
| 116 | }), | ||
| 117 | }); | ||
| 118 | |||
| 119 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 120 | |||
| 121 | expect( | ||
| 122 | keys.map((key) => key.id), | ||
| 123 | "only this org's key survives", | ||
| 124 | ).toEqual(["key_mine"]); | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | |||
| 128 | it.effect("refuses a create response that names another organization", () => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const apiKeys = yield* serviceWith({ | ||
| 131 | createOrgApiKey: () => | ||
| 132 | Effect.succeed({ | ||
| 133 | ...orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 134 | value: "sk_org_plaintext", | ||
| 135 | }), | ||
| 136 | }); | ||
| 137 | |||
| 138 | const error = yield* Effect.flip( | ||
| 139 | apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(error).toBeInstanceOf(ApiKeyManagementError); | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.effect("ignores a user-owned key in an org-key listing", () => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | // The org and user shapes are decoded separately on purpose; a user key | ||
| 149 | // appearing here would mean the caller could mistake a member's personal | ||
| 150 | // credential for a workspace one. | ||
| 151 | const apiKeys = yield* serviceWith({ | ||
| 152 | listOrgApiKeys: () => | ||
| 153 | Effect.succeed({ | ||
| 154 | object: "list", | ||
| 155 | data: [ | ||
| 156 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 157 | { | ||
| 158 | object: "api_key", | ||
| 159 | id: "key_user", | ||
| 160 | owner: { type: "user", id: "user_1", organization_id: ORG }, | ||
| 161 | name: "personal", | ||
| 162 | obfuscatedValue: "sk_user…c3d4", | ||
| 163 | lastUsedAt: null, | ||
| 164 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 165 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 166 | }, | ||
| 167 | ], | ||
| 168 | listMetadata: { before: null, after: null }, | ||
| 169 | }), | ||
| 170 | }); | ||
| 171 | |||
| 172 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 173 | |||
| 174 | expect( | ||
| 175 | keys.map((key) => key.id), | ||
| 176 | "a user-owned key is not an org key", | ||
| 177 | ).toEqual(["key_mine"]); | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | }); | ||
| 181 | |||
| 182 | // --------------------------------------------------------------------------- | ||
| 183 | // Revoking an org key. The mint is only half a credential lifecycle: an org key | ||
| 184 | // reads every user and connection in the tenant, so it has to be destroyable. | ||
| 185 | // | ||
| 186 | // Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves | ||
| 187 | // ownership against the CALLER's personal keys, so an org key id is never in | ||
| 188 | // its owned set. It also cannot just call `deleteApiKey` directly: that route is | ||
| 189 | // scope-blind and would happily destroy a member's personal key, or a key | ||
| 190 | // belonging to a sibling org in the same WorkOS workspace. So ownership is | ||
| 191 | // resolved against the ORG's decoded listing first, and that filter IS the | ||
| 192 | // authorization. These pin that filter. | ||
| 193 | // --------------------------------------------------------------------------- | ||
| 194 | |||
| 195 | describe("revoking an organization API key", () => { | ||
| 196 | const listing = (...keys: ReadonlyArray<unknown>) => ({ | ||
| 197 | object: "list", | ||
| 198 | data: keys, | ||
| 199 | listMetadata: { before: null, after: null }, | ||
| 200 | }); | ||
| 201 | |||
| 202 | it.effect("revokes a key the organization owns", () => | ||
| 203 | Effect.gen(function* () { | ||
| 204 | const deleted: string[] = []; | ||
| 205 | const apiKeys = yield* serviceWith({ | ||
| 206 | listOrgApiKeys: (organizationId: string) => | ||
| 207 | Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | ||
| 208 | deleteApiKey: (id: string) => | ||
| 209 | Effect.sync(() => { | ||
| 210 | deleted.push(id); | ||
| 211 | }), | ||
| 212 | }); | ||
| 213 | |||
| 214 | yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }); | ||
| 215 | |||
| 216 | expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([ | ||
| 217 | "key_org_1", | ||
| 218 | ]); | ||
| 219 | }), | ||
| 220 | ); | ||
| 221 | |||
| 222 | it.effect("refuses a USER-owned key id, without deleting anything", () => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | // The regression this guards: a member's personal credential must not be | ||
| 225 | // destroyable through the org route just because an admin can call it. | ||
| 226 | const deleted: string[] = []; | ||
| 227 | const apiKeys = yield* serviceWith({ | ||
| 228 | listOrgApiKeys: () => | ||
| 229 | Effect.succeed( | ||
| 230 | listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), { | ||
| 231 | object: "api_key", | ||
| 232 | id: "key_user", | ||
| 233 | owner: { type: "user", id: "user_1", organization_id: ORG }, | ||
| 234 | name: "personal", | ||
| 235 | obfuscatedValue: "sk_user…c3d4", | ||
| 236 | lastUsedAt: null, | ||
| 237 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 238 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | deleteApiKey: (id: string) => | ||
| 242 | Effect.sync(() => { | ||
| 243 | deleted.push(id); | ||
| 244 | }), | ||
| 245 | }); | ||
| 246 | |||
| 247 | const error = yield* Effect.flip( | ||
| 248 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }), | ||
| 249 | ); | ||
| 250 | |||
| 251 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 252 | expect(deleted, "no delete is issued for a key the org does not own").toEqual([]); | ||
| 253 | }), | ||
| 254 | ); | ||
| 255 | |||
| 256 | it.effect("refuses a key belonging to ANOTHER organization", () => | ||
| 257 | Effect.gen(function* () { | ||
| 258 | // The WorkOS workspace key is workspace-wide, so an admin of one org must | ||
| 259 | // not be able to revoke a sibling org's credential by id. | ||
| 260 | const deleted: string[] = []; | ||
| 261 | const apiKeys = yield* serviceWith({ | ||
| 262 | listOrgApiKeys: () => | ||
| 263 | Effect.succeed( | ||
| 264 | listing( | ||
| 265 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 266 | orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 267 | ), | ||
| 268 | ), | ||
| 269 | deleteApiKey: (id: string) => | ||
| 270 | Effect.sync(() => { | ||
| 271 | deleted.push(id); | ||
| 272 | }), | ||
| 273 | }); | ||
| 274 | |||
| 275 | const error = yield* Effect.flip( | ||
| 276 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 280 | expect(deleted).toEqual([]); | ||
| 281 | }), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect("refuses an unknown key id", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const apiKeys = yield* serviceWith({ | ||
| 287 | listOrgApiKeys: () => Effect.succeed(listing()), | ||
| 288 | deleteApiKey: () => Effect.die("nothing should be deleted"), | ||
| 289 | }); | ||
| 290 | |||
| 291 | const error = yield* Effect.flip( | ||
| 292 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }), | ||
| 293 | ); | ||
| 294 | |||
| 295 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 296 | }), | ||
| 297 | ); | ||
| 298 | |||
| 299 | it.effect("surfaces an upstream delete failure as a management error", () => | ||
| 300 | Effect.gen(function* () { | ||
| 301 | // A failed delete must not read as a successful revoke — the console | ||
| 302 | // would tell an admin a live credential is gone. | ||
| 303 | const apiKeys = yield* serviceWith({ | ||
| 304 | listOrgApiKeys: (organizationId: string) => | ||
| 305 | Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | ||
| 306 | deleteApiKey: () => Effect.fail("workos_unavailable"), | ||
| 307 | }); | ||
| 308 | |||
| 309 | const error = yield* Effect.flip( | ||
| 310 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }), | ||
| 311 | ); | ||
| 312 | |||
| 313 | expect(error).toBeInstanceOf(ApiKeyManagementError); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | }); | ||
| @@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines | |||
| 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 8 | 8 | ||
| 9 | // The org a console request resolves to is the URL's org (sent in the | 9 | // The org a console request resolves to is the URL's org (sent in the |
| 10 | // `x-executor-organization` selector header), not the session's stored org — | 10 | // `x-executor-organization` selector header) — NEVER the session's stored org. |
| 11 | // with the session org as the fallback for non-console callers, and live | 11 | // The sealed cookie's org is a browser-global pinned to whichever org WorkOS |
| 12 | // membership re-checked either way. This is what makes two browser tabs on | 12 | // last touched, so a fallback to it silently scopes a multi-org user's request |
| 13 | // to the wrong org; a header-less request fails closed instead. Live | ||
| 14 | // membership is re-checked either way. This is what makes two browser tabs on | ||
| 13 | // different orgs independent. | 15 | // different orgs independent. |
| 14 | 16 | ||
| 15 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | 17 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); |
| @@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines | |||
| 27 | listUserKeys: () => Effect.succeed([]), | 29 | listUserKeys: () => Effect.succeed([]), |
| 28 | createUserKey: () => Effect.die("not used"), | 30 | createUserKey: () => Effect.die("not used"), |
| 29 | revokeUserKey: () => Effect.void, | 31 | revokeUserKey: () => Effect.void, |
| 32 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 33 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 34 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 30 | }); | 35 | }); |
| 31 | 36 | ||
| 32 | const stubWorkOS = Layer.succeed( | 37 | const stubWorkOS = Layer.succeed( |
| @@ -59,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 26 unmodified lines | |||
| 59 | ); | 64 | ); |
| 60 | 65 | ||
| 61 | const stubUsers = Layer.succeed(UserStoreService)({ | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 62 | use: (fn) => | 67 | use: (_op, fn) => |
| 63 | Effect.promise(() => | 68 | Effect.promise(() => |
| 64 | fn({ | 69 | fn({ |
| 65 | ensureAccount: async (id: string) => ({ id, createdAt }), | 70 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -95,10 +100,15 @@ const run = (headers: Record<string, string>) => · 29 unmodified lines | |||
| 95 | ); | 100 | ); |
| 96 | 101 | ||
| 97 | describe("resolveSessionPrincipal · URL org selector", () => { | 102 | describe("resolveSessionPrincipal · URL org selector", () => { |
| 98 | it.effect("falls back to the session org when no selector header is sent", () => | 103 | it.effect("fails closed when no selector header is sent", () => |
| 99 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 100 | const principal = yield* run({ cookie: "wos-session=x" }); | 105 | // No fallback to the session org: the cookie's org is browser-global |
| 101 | expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG); | 106 | // and can name a different org than the tab's URL for a multi-org user. |
| 107 | const error = yield* Effect.flip(run({ cookie: "wos-session=x" })); | ||
| 108 | expect(error, "rejects instead of scoping to the session org").toMatchObject({ | ||
| 109 | _tag: "NoOrganization", | ||
| 110 | code: "no_organization", | ||
| 111 | }); | ||
| 102 | }), | 112 | }), |
| 103 | ); | 113 | ); |
| 104 | 114 | ||
| @@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; · 37 unmodified lines | |||
| 38 | export const resolveOrganization = (organizationId: string) => | 38 | export const resolveOrganization = (organizationId: string) => |
| 39 | Effect.gen(function* () { | 39 | Effect.gen(function* () { |
| 40 | const users = yield* UserStoreService; | 40 | const users = yield* UserStoreService; |
| 41 | const existing = yield* users.use((s) => s.getOrganization(organizationId)); | 41 | const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); |
| 42 | if (existing) return existing; | 42 | if (existing) return existing; |
| 43 | 43 | ||
| 44 | const workos = yield* WorkOSClient; | 44 | const workos = yield* WorkOSClient; |
| 45 | const fresh = yield* workos.getOrganization(organizationId); | 45 | const fresh = yield* workos.getOrganization(organizationId); |
| 46 | return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); | 46 | return yield* users.use("upsertOrganization", (s) => |
| 47 | s.upsertOrganization({ id: fresh.id, name: fresh.name }), | ||
| 48 | ); | ||
| 47 | }); | 49 | }); |
| 48 | 50 | ||
| 49 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| @@ -125,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) · 75 unmodified lines | |||
| 125 | return yield* authorizeOrganization(userId, selector); | 127 | return yield* authorizeOrganization(userId, selector); |
| 126 | } | 128 | } |
| 127 | const users = yield* UserStoreService; | 129 | const users = yield* UserStoreService; |
| 128 | const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); | 130 | const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); |
| 129 | if (!org) return null; | 131 | if (!org) return null; |
| 130 | return yield* authorizeOrganization(userId, org.id); | 132 | return yield* authorizeOrganization(userId, org.id); |
| 131 | }); | 133 | }); |
| @@ -0,0 +1,128 @@ | |||
| 1 | // `UserStoreError` is the public failure of every cloud user-store call, and it | ||
| 2 | // used to carry NOTHING: no operation, no reason, no message. Sentry grouped | ||
| 3 | // every store failure — a connect timeout, a missing table, a constraint | ||
| 4 | // violation — into one titleless issue, and the only cause detail (the | ||
| 5 | // pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed | ||
| 6 | // server-side, so the issue was undiagnosable from Sentry alone. | ||
| 7 | // | ||
| 8 | // This pins the two safe classification fields it must carry instead: | ||
| 9 | // `operation` (already in hand at the call site) and `reason` (classified from | ||
| 10 | // the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes). | ||
| 11 | import { createServer, type Server, type Socket } from "node:net"; | ||
| 12 | |||
| 13 | import { describe, expect, it } from "@effect/vitest"; | ||
| 14 | import { Effect, Layer, Result } from "effect"; | ||
| 15 | import postgres from "postgres"; | ||
| 16 | |||
| 17 | import { UserStoreService } from "./context"; | ||
| 18 | import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors"; | ||
| 19 | import { DbService } from "../db/db"; | ||
| 20 | |||
| 21 | // A socket that completes the TCP handshake and then says nothing — exactly | ||
| 22 | // what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the | ||
| 23 | // only way to obtain the driver's REAL connect-timeout error object rather than | ||
| 24 | // a hand-written imitation of it. | ||
| 25 | const blackHolePort = async (): Promise<{ | ||
| 26 | readonly port: number; | ||
| 27 | readonly close: () => Promise<void>; | ||
| 28 | }> => { | ||
| 29 | const sockets = new Set<Socket>(); | ||
| 30 | const server: Server = createServer((socket) => { | ||
| 31 | sockets.add(socket); | ||
| 32 | socket.on("close", () => sockets.delete(socket)); | ||
| 33 | }); | ||
| 34 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 35 | const address = server.address(); | ||
| 36 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port | ||
| 37 | if (address === null || typeof address === "string") throw new Error("no port"); | ||
| 38 | return { | ||
| 39 | port: address.port, | ||
| 40 | close: () => | ||
| 41 | new Promise<void>((resolve) => { | ||
| 42 | // The timed-out client leaves its half-open socket attached; without | ||
| 43 | // dropping it first, `close` waits for a peer that will never speak. | ||
| 44 | for (const socket of sockets) socket.destroy(); | ||
| 45 | server.close(() => resolve()); | ||
| 46 | }), | ||
| 47 | }; | ||
| 48 | }; | ||
| 49 | |||
| 50 | const realConnectTimeoutError = async (): Promise<unknown> => { | ||
| 51 | const hole = await blackHolePort(); | ||
| 52 | const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, { | ||
| 53 | max: 1, | ||
| 54 | connect_timeout: 1, | ||
| 55 | fetch_types: false, | ||
| 56 | onnotice: () => undefined, | ||
| 57 | }); | ||
| 58 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture | ||
| 59 | try { | ||
| 60 | await sql`select 1`; | ||
| 61 | // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered | ||
| 62 | throw new Error("expected the connect to time out"); | ||
| 63 | } catch (error) { | ||
| 64 | return error; | ||
| 65 | } finally { | ||
| 66 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened | ||
| 67 | await sql.end({ timeout: 0 }).catch(() => undefined); | ||
| 68 | await hole.close(); | ||
| 69 | } | ||
| 70 | }; | ||
| 71 | |||
| 72 | // Drizzle re-throws driver failures wrapped in its own error with the failing | ||
| 73 | // SQL in the message and the driver error in `.cause` — the shape production | ||
| 74 | // actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`). | ||
| 75 | const wrappedLikeDrizzle = (cause: unknown): Error => | ||
| 76 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with | ||
| 77 | Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), { | ||
| 78 | cause, | ||
| 79 | }); | ||
| 80 | |||
| 81 | const stubDb = Layer.succeed(DbService)({ db: {} as never }); | ||
| 82 | |||
| 83 | const failingStoreCall = ( | ||
| 84 | operation: string, | ||
| 85 | failure: unknown, | ||
| 86 | ): Effect.Effect<Result.Result<never, UserStoreError>> => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const users = yield* UserStoreService; | ||
| 89 | // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture | ||
| 90 | return yield* users.use(operation, () => Promise.reject(failure)); | ||
| 91 | }).pipe( | ||
| 92 | Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))), | ||
| 93 | Effect.result, | ||
| 94 | ) as Effect.Effect<Result.Result<never, UserStoreError>>; | ||
| 95 | |||
| 96 | describe("UserStoreError classification", () => { | ||
| 97 | it("classifies the driver cause chain", async () => { | ||
| 98 | const driverError = await realConnectTimeoutError(); | ||
| 99 | |||
| 100 | expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout"); | ||
| 101 | expect( | ||
| 102 | userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })), | ||
| 103 | ).toBe("connect_timeout"); | ||
| 104 | expect( | ||
| 105 | userStoreReasonFromCause( | ||
| 106 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape | ||
| 107 | Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }), | ||
| 108 | ), | ||
| 109 | ).toBe("query"); | ||
| 110 | // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify | ||
| 111 | expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown"); | ||
| 112 | }, 20_000); | ||
| 113 | |||
| 114 | it("carries operation, reason and a stable message on the public error", async () => { | ||
| 115 | const driverError = await realConnectTimeoutError(); | ||
| 116 | |||
| 117 | const result = await Effect.runPromise( | ||
| 118 | failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)), | ||
| 119 | ); | ||
| 120 | |||
| 121 | expect(Result.isFailure(result)).toBe(true); | ||
| 122 | if (!Result.isFailure(result)) return; | ||
| 123 | expect(result.failure.operation).toBe("getOrganization"); | ||
| 124 | expect(result.failure.reason).toBe("connect_timeout"); | ||
| 125 | expect(result.failure.message).toContain("getOrganization"); | ||
| 126 | expect(result.failure.message).toContain("connect_timeout"); | ||
| 127 | }, 20_000); | ||
| 128 | }); | ||
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | // - invalid api key -> Unauthorized 401 invalid_api_key | 16 | // - invalid api key -> Unauthorized 401 invalid_api_key |
| 17 | // - api-key org not authorized -> NoOrganization 403 no_organization | 17 | // - api-key org not authorized -> NoOrganization 403 no_organization |
| 18 | // - no/invalid session -> NoOrganization 403 no_organization | 18 | // - no/invalid session -> NoOrganization 403 no_organization |
| 19 | // - session without org header -> NoOrganization 403 no_organization (fail closed) | ||
| 19 | // - session org not authorized -> NoOrganization 403 no_organization | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 20 | // - no auth header -> falls through to the sealed-session path | 21 | // - no auth header -> falls through to the sealed-session path |
| 21 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are | 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are |
| @@ -37,7 +38,13 @@ import { · 15 unmodified lines | |||
| 37 | Unauthorized, | 38 | Unauthorized, |
| 38 | Unavailable, | 39 | Unavailable, |
| 39 | } from "@executor-js/api/server"; | 40 | } from "@executor-js/api/server"; |
| 40 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; | 41 | import type { |
| 42 | FailureRenderingStrategy, | ||
| 43 | IdentityFailure, | ||
| 44 | PlatformPrincipal, | ||
| 45 | Principal, | ||
| 46 | ResolvedPrincipal, | ||
| 47 | } from "@executor-js/api/server"; | ||
| 41 | 48 | ||
| 42 | import { ApiKeyService } from "./api-keys"; | 49 | import { ApiKeyService } from "./api-keys"; |
| 43 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | 50 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; |
| @@ -46,6 +53,7 @@ import { · 2 unmodified lines | |||
| 46 | authorizeOrganization, | 53 | authorizeOrganization, |
| 47 | authorizeOrganizationSelector, | 54 | authorizeOrganizationSelector, |
| 48 | orgSelectorFromRequest, | 55 | orgSelectorFromRequest, |
| 56 | resolveOrganization, | ||
| 49 | } from "./organization"; | 57 | } from "./organization"; |
| 50 | import { UserStoreService } from "./context"; | 58 | import { UserStoreService } from "./context"; |
| 51 | import { sealedSessionDisplayName } from "./middleware"; | 59 | import { sealedSessionDisplayName } from "./middleware"; |
| @@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines | |||
| 86 | code: "no_organization", | 94 | code: "no_organization", |
| 87 | message: "No organization in session", | 95 | message: "No organization in session", |
| 88 | }; | 96 | }; |
| 97 | const NO_ORGANIZATION_SELECTOR = { | ||
| 98 | code: "no_organization", | ||
| 99 | message: "No organization selector in request", | ||
| 100 | }; | ||
| 89 | const INVALID_ACCESS_TOKEN = { | 101 | const INVALID_ACCESS_TOKEN = { |
| 90 | code: "invalid_access_token", | 102 | code: "invalid_access_token", |
| 91 | message: "Invalid or expired access token", | 103 | message: "Invalid or expired access token", |
| @@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines | |||
| 98 | code: "no_organization", | 110 | code: "no_organization", |
| 99 | message: "No organization in access token", | 111 | message: "No organization in access token", |
| 100 | }; | 112 | }; |
| 101 | |||
| 102 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access | 113 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access |
| 103 | // token from the CLI device-login); anything else is treated as an API key. | 114 | // token from the CLI device-login); anything else is treated as an API key. |
| 104 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). | 115 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). |
| @@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines | |||
| 134 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); | 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); |
| 135 | 146 | ||
| 136 | return { | 147 | return { |
| 148 | kind: "member", | ||
| 137 | accountId: verified.accountId, | 149 | accountId: verified.accountId, |
| 138 | organizationId: org.id, | 150 | organizationId: org.id, |
| 139 | organizationName: org.name, | 151 | organizationName: org.name, |
| @@ -146,13 +158,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 6 unmodified lines | |||
| 146 | }); | 158 | }); |
| 147 | 159 | ||
| 148 | /** | 160 | /** |
| 149 | * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT | 161 | * What an ORG-level API key resolves to: the platform view. Deliberately NOT a |
| 150 | * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS | 162 | * `Principal` — a `Principal` names an acting member (`accountId: string`), and |
| 151 | * API key. Returns `null` when there is no `Authorization` header, so the | 163 | * an org key has none. Keeping it a separate shape means no product code path |
| 152 | * caller falls through to the sealed-session path. (Kept the historical name, | 164 | * can accidentally treat it as a member, and the executor built from it binds |
| 153 | * the re-export and resolver tests reference it.) | 165 | * `subject: null` + the read-only tenant reach rather than inventing a subject. |
| 166 | * | ||
| 167 | * The `/admin/*` mount turns this into an executor with `{ tenant: | ||
| 168 | * organizationId, subject: undefined, platformView: true }`. | ||
| 169 | */ | ||
| 170 | export interface PlatformAuth { | ||
| 171 | readonly kind: "platform"; | ||
| 172 | readonly organizationId: string; | ||
| 173 | readonly organizationName: string; | ||
| 174 | readonly organizationSlug?: string; | ||
| 175 | readonly keyId: string; | ||
| 176 | } | ||
| 177 | |||
| 178 | /** A resolved bearer credential: an acting member, or the org-level platform | ||
| 179 | * view. `null` means no `Authorization` header at all. */ | ||
| 180 | export type BearerAuth = Principal | PlatformAuth | null; | ||
| 181 | |||
| 182 | export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth => | ||
| 183 | value !== null && "kind" in value && value.kind === "platform"; | ||
| 184 | |||
| 185 | /** | ||
| 186 | * Resolve a `Bearer` credential into either a member `Principal` or the | ||
| 187 | * org-level {@link PlatformAuth}. Returns `null` when there is no | ||
| 188 | * `Authorization` header, so the caller falls through to the sealed-session | ||
| 189 | * path. | ||
| 190 | * | ||
| 191 | * The org branch does NOT call `authorizeOrganization`: that checks a USER's | ||
| 192 | * live membership, and there is no user here. The key itself is the authority — | ||
| 193 | * WorkOS validated it and reported which org owns it — so the org row is merely | ||
| 194 | * resolved (mirrored on first read) for its name and slug. | ||
| 154 | */ | 195 | */ |
| 155 | export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => | 196 | export const resolveBearerAuth = ( |
| 197 | request: Request, | ||
| 198 | jwt: JwtBearerConfig | null = null, | ||
| 199 | ): Effect.Effect< | ||
| 200 | BearerAuth, | ||
| 201 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | ||
| 202 | WorkOSClient | ApiKeyService | UserStoreService | ||
| 203 | > => | ||
| 156 | Effect.gen(function* () { | 204 | Effect.gen(function* () { |
| 157 | const authHeader = request.headers.get("authorization"); | 205 | const authHeader = request.headers.get("authorization"); |
| 158 | if (!authHeader) return null; | 206 | if (!authHeader) return null; |
| @@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines | |||
| 167 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); | 215 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); |
| 168 | 216 | ||
| 169 | const apiKeys = yield* ApiKeyService; | 217 | const apiKeys = yield* ApiKeyService; |
| 170 | const principal = yield* apiKeys | 218 | const owner = yield* apiKeys |
| 171 | .validate(value) | 219 | .validate(value) |
| 172 | .pipe( | 220 | .pipe( |
| 173 | Effect.catchTag("ApiKeyValidationError", () => | 221 | Effect.catchTag("ApiKeyValidationError", () => |
| @@ -175,13 +223,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line | |||
| 175 | ), | 223 | ), |
| 176 | ); | 224 | ); |
| 177 | 225 | ||
| 178 | if (!principal) return yield* new Unauthorized(INVALID_API_KEY); | 226 | if (!owner) return yield* new Unauthorized(INVALID_API_KEY); |
| 179 | 227 | ||
| 180 | const org = yield* authorizeOrganization(principal.accountId, principal.organizationId); | 228 | if (owner.scope === "org") { |
| 229 | const org = yield* resolveOrganization(owner.organizationId); | ||
| 230 | return { | ||
| 231 | kind: "platform", | ||
| 232 | organizationId: org.id, | ||
| 233 | organizationName: org.name, | ||
| 234 | ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }), | ||
| 235 | keyId: owner.keyId, | ||
| 236 | } satisfies PlatformAuth; | ||
| 237 | } | ||
| 238 | |||
| 239 | // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the | ||
| 240 | // guard keeps the narrowing honest rather than asserting. | ||
| 241 | if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY); | ||
| 242 | |||
| 243 | const org = yield* authorizeOrganization(owner.accountId, owner.organizationId); | ||
| 181 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); | 244 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 182 | 245 | ||
| 183 | return { | 246 | return { |
| 184 | accountId: principal.accountId, | 247 | kind: "member", |
| 248 | accountId: owner.accountId, | ||
| 185 | organizationId: org.id, | 249 | organizationId: org.id, |
| 186 | organizationName: org.name, | 250 | organizationName: org.name, |
| 187 | organizationSlug: org.slug, | 251 | organizationSlug: org.slug, |
| @@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines | |||
| 192 | } satisfies Principal; | 256 | } satisfies Principal; |
| 193 | }); | 257 | }); |
| 194 | 258 | ||
| 259 | /** | ||
| 260 | * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral | ||
| 261 | * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared | ||
| 262 | * middleware routes it to the subject-less, read-only platform executor | ||
| 263 | * (refusing non-GET up front). Previously the product plane rejected org keys | ||
| 264 | * outright; serving tenant-level READS to them is deliberate: the catalog, | ||
| 265 | * tools, policies, and org-owned connection listings are tenant-shared answers | ||
| 266 | * a machine credential can honestly receive, while everything subject-bound | ||
| 267 | * stays structurally out of reach (a platform executor binds no subject, so no | ||
| 268 | * member's personal rows resolve). (Kept the historical name; the re-export and | ||
| 269 | * resolver tests reference it.) | ||
| 270 | */ | ||
| 271 | export const resolveApiKeyPrincipal = ( | ||
| 272 | request: Request, | ||
| 273 | jwt: JwtBearerConfig | null = null, | ||
| 274 | ): Effect.Effect< | ||
| 275 | ResolvedPrincipal | null, | ||
| 276 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | ||
| 277 | WorkOSClient | ApiKeyService | UserStoreService | ||
| 278 | > => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const auth = yield* resolveBearerAuth(request, jwt); | ||
| 281 | if (isPlatformAuth(auth)) { | ||
| 282 | return { | ||
| 283 | kind: "platform", | ||
| 284 | organizationId: auth.organizationId, | ||
| 285 | organizationName: auth.organizationName, | ||
| 286 | ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }), | ||
| 287 | keyId: auth.keyId, | ||
| 288 | } satisfies PlatformPrincipal; | ||
| 289 | } | ||
| 290 | return auth; | ||
| 291 | }); | ||
| 292 | |||
| 195 | export const resolveSessionPrincipal = (request: Request) => | 293 | export const resolveSessionPrincipal = (request: Request) => |
| 196 | Effect.gen(function* () { | 294 | Effect.gen(function* () { |
| 197 | const workos = yield* WorkOSClient; | 295 | const workos = yield* WorkOSClient; |
| @@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line | |||
| 199 | if (!session) { | 297 | if (!session) { |
| 200 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 298 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 201 | } | 299 | } |
| 202 | // The console URL's org is the scope authority (sent as a header); the | 300 | // The console URL's org is the scope authority, sent as a header on every |
| 203 | // session's own org is the fallback for non-console callers. Membership is | 301 | // request. FAIL CLOSED when it's missing: the sealed cookie's org is |
| 204 | // re-checked live either way — the header is a selector, not a trust | 302 | // browser-global and pinned to whichever org WorkOS last touched, so |
| 205 | // boundary (see organization.ts). | 303 | // falling back to it silently serves ANOTHER org's data to a multi-org |
| 206 | const selector = orgSelectorFromRequest(request) ?? session.organizationId; | 304 | // user (the wrong-tenant connection-list bug, 2026-07). A header-less |
| 305 | // session call gets a clear 403 instead. Membership is re-checked live — | ||
| 306 | // the header is a selector, not a trust boundary (see organization.ts). | ||
| 307 | // A bare-URL first paint (no org in the path yet) may 403 here; that's | ||
| 308 | // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto | ||
| 309 | // an org slug, the org-keyed atom registry remounts, and everything | ||
| 310 | // refetches with the header. `/account/me` — which deliberately DOES fall | ||
| 311 | // back to the session org to pick that landing org — lives on the account | ||
| 312 | // plane, not here. | ||
| 313 | const selector = orgSelectorFromRequest(request); | ||
| 207 | if (!selector) { | 314 | if (!selector) { |
| 208 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 315 | return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR); |
| 209 | } | 316 | } |
| 210 | const org = yield* authorizeOrganizationSelector(session.userId, selector); | 317 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 211 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 318 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 212 | return { | 319 | return { |
| 320 | kind: "member", | ||
| 213 | accountId: session.userId, | 321 | accountId: session.userId, |
| 214 | organizationId: org.id, | 322 | organizationId: org.id, |
| 215 | organizationName: org.name, | 323 | organizationName: org.name, |
| @@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines | |||
| 236 | request: Request, | 344 | request: Request, |
| 237 | jwt: JwtBearerConfig | null = null, | 345 | jwt: JwtBearerConfig | null = null, |
| 238 | ): Effect.Effect< | 346 | ): Effect.Effect< |
| 239 | Principal, | 347 | ResolvedPrincipal, |
| 240 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | 348 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 241 | WorkOSClient | ApiKeyService | UserStoreService | 349 | WorkOSClient | ApiKeyService | UserStoreService |
| 242 | > => | 350 | > => |
| @@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines | |||
| 317 | "service_unavailable", | 425 | "service_unavailable", |
| 318 | "Service temporarily unavailable", | 426 | "Service temporarily unavailable", |
| 319 | ), | 427 | ), |
| 428 | ReadOnlyCredential: renderIdentityFailure( | ||
| 429 | 403, | ||
| 430 | "read_only_credential", | ||
| 431 | "Organization API keys are read-only", | ||
| 432 | ), | ||
| 320 | }), | 433 | }), |
| 321 | ), | 434 | ), |
| 322 | }; | 435 | }; |
| @@ -1,6 +1,287 @@ | |||
| 1 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | ||
| 2 | import type { AddressInfo } from "node:net"; | ||
| 3 | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import { env } from "cloudflare:workers"; | ||
| 6 | import { Effect, Schema } from "effect"; | ||
| 7 | import { defaults as ironDefaults, seal as sealIron } from "iron-webcrypto"; | ||
| 8 | import { | ||
| 9 | SignJWT, | ||
| 10 | exportJWK, | ||
| 11 | generateKeyPair, | ||
| 12 | type JSONWebKeySet, | ||
| 13 | type JWK, | ||
| 14 | type KeyLike, | ||
| 15 | } from "jose"; | ||
| 16 | |||
| 17 | import { WorkOSClient, collectRawWorkOSList, collectWorkOSList } from "./workos"; | ||
| 18 | |||
| 19 | const COOKIE_PASSWORD = "test_cookie_password_at_least_32_chars!"; | ||
| 20 | const CLIENT_ID = "client_test"; | ||
| 21 | const API_KEY = "sk_test"; | ||
| 22 | const USER = { | ||
| 23 | id: "user_test", | ||
| 24 | email: "test@example.com", | ||
| 25 | firstName: "Test", | ||
| 26 | lastName: "User", | ||
| 27 | profilePictureUrl: "https://example.com/avatar.png", | ||
| 28 | } as const; | ||
| 29 | |||
| 30 | interface Keypair { | ||
| 31 | readonly kid: string; | ||
| 32 | readonly publicJwk: JWK; | ||
| 33 | readonly privateKey: KeyLike; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface RecordedRequest { | ||
| 37 | readonly method: string; | ||
| 38 | readonly path: string; | ||
| 39 | readonly body: unknown; | ||
| 40 | } | ||
| 41 | |||
| 42 | interface WorkOSStub { | ||
| 43 | readonly baseUrl: string; | ||
| 44 | readonly requests: () => readonly RecordedRequest[]; | ||
| 45 | readonly setKeys: (keys: ReadonlyArray<JWK>) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | const generateKeypair = async (kid: string): Promise<Keypair> => { | ||
| 49 | const { publicKey, privateKey } = await generateKeyPair("RS256"); | ||
| 50 | const jwk = await exportJWK(publicKey); | ||
| 51 | return { kid, publicJwk: { ...jwk, kid, alg: "RS256" }, privateKey }; | ||
| 52 | }; | ||
| 53 | |||
| 54 | const signAccessToken = ( | ||
| 55 | keypair: Keypair, | ||
| 56 | claims: { | ||
| 57 | readonly subject?: string; | ||
| 58 | readonly organizationId?: string; | ||
| 59 | readonly sessionId?: string; | ||
| 60 | readonly expiresIn?: string | number; | ||
| 61 | } = {}, | ||
| 62 | ) => { | ||
| 63 | const jwt = new SignJWT({ | ||
| 64 | org_id: claims.organizationId ?? "org_test", | ||
| 65 | sid: claims.sessionId ?? "session_test", | ||
| 66 | }) | ||
| 67 | .setProtectedHeader({ alg: "RS256", kid: keypair.kid }) | ||
| 68 | .setSubject(claims.subject ?? USER.id) | ||
| 69 | .setIssuedAt(); | ||
| 70 | |||
| 71 | return ( | ||
| 72 | typeof claims.expiresIn === "number" | ||
| 73 | ? jwt.setExpirationTime(claims.expiresIn) | ||
| 74 | : jwt.setExpirationTime(claims.expiresIn ?? "5m") | ||
| 75 | ).sign(keypair.privateKey); | ||
| 76 | }; | ||
| 77 | |||
| 78 | const sealSession = async (accessToken: string, refreshToken = "refresh_test"): Promise<string> => | ||
| 79 | `${await sealIron( | ||
| 80 | { | ||
| 81 | accessToken, | ||
| 82 | refreshToken, | ||
| 83 | user: USER, | ||
| 84 | }, | ||
| 85 | { id: "1", secret: COOKIE_PASSWORD }, | ||
| 86 | { ...ironDefaults, ttl: 0, encode: JSON.stringify }, | ||
| 87 | )}~2`; | ||
| 88 | |||
| 89 | const workosUserResponse = { | ||
| 90 | object: "user", | ||
| 91 | id: USER.id, | ||
| 92 | email: USER.email, | ||
| 93 | email_verified: true, | ||
| 94 | profile_picture_url: USER.profilePictureUrl, | ||
| 95 | first_name: USER.firstName, | ||
| 96 | last_name: USER.lastName, | ||
| 97 | last_sign_in_at: null, | ||
| 98 | locale: null, | ||
| 99 | created_at: "2026-07-09T00:00:00.000Z", | ||
| 100 | updated_at: "2026-07-09T00:00:00.000Z", | ||
| 101 | external_id: null, | ||
| 102 | metadata: {}, | ||
| 103 | } as const; | ||
| 104 | |||
| 105 | const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 106 | |||
| 107 | const readJsonBody = async (request: IncomingMessage): Promise<unknown> => { | ||
| 108 | const chunks: Buffer[] = []; | ||
| 109 | for await (const chunk of request) { | ||
| 110 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 111 | } | ||
| 112 | const body = Buffer.concat(chunks).toString("utf8"); | ||
| 113 | return body ? decodeJsonBody(body) : null; | ||
| 114 | }; | ||
| 115 | |||
| 116 | const writeJson = (response: ServerResponse, status: number, body: unknown): void => { | ||
| 117 | response.writeHead(status, { "content-type": "application/json" }); | ||
| 118 | response.end(JSON.stringify(body)); | ||
| 119 | }; | ||
| 120 | |||
| 121 | const withWorkOSStub = async <A>( | ||
| 122 | keypair: Keypair, | ||
| 123 | use: (stub: WorkOSStub) => Promise<A>, | ||
| 124 | ): Promise<A> => { | ||
| 125 | let keys: ReadonlyArray<JWK> = [keypair.publicJwk]; | ||
| 126 | const requests: RecordedRequest[] = []; | ||
| 127 | const server = createServer((request, response) => { | ||
| 128 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: Node's request callback cannot await, so the fixture converts async failures into a stable HTTP 500 | ||
| 129 | void (async () => { | ||
| 130 | const path = request.url ?? "/"; | ||
| 131 | const body = request.method === "POST" ? await readJsonBody(request) : null; | ||
| 132 | requests.push({ method: request.method ?? "GET", path, body }); | ||
| 133 | |||
| 134 | if (request.method === "GET" && path === `/sso/jwks/${CLIENT_ID}`) { | ||
| 135 | writeJson(response, 200, { keys: keys.map((key) => ({ ...key })) } satisfies JSONWebKeySet); | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (request.method === "POST" && path === "/user_management/authenticate") { | ||
| 140 | const refreshed = await signAccessToken(keypair, { | ||
| 141 | sessionId: "session_refreshed", | ||
| 142 | }); | ||
| 143 | writeJson(response, 200, { | ||
| 144 | user: workosUserResponse, | ||
| 145 | organization_id: "org_test", | ||
| 146 | access_token: refreshed, | ||
| 147 | refresh_token: "refresh_next", | ||
| 148 | authentication_method: "Password", | ||
| 149 | }); | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | |||
| 153 | writeJson(response, 404, { error: "unexpected request" }); | ||
| 154 | })().catch((_error: unknown) => { | ||
| 155 | writeJson(response, 500, { | ||
| 156 | error: "fixture server failed", | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | }); | ||
| 160 | |||
| 161 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 162 | const address = server.address() as AddressInfo; | ||
| 163 | const stub: WorkOSStub = { | ||
| 164 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 165 | requests: () => requests, | ||
| 166 | setKeys: (next) => { | ||
| 167 | keys = next; | ||
| 168 | }, | ||
| 169 | }; | ||
| 170 | |||
| 171 | return use(stub).finally(() => { | ||
| 172 | server.closeAllConnections(); | ||
| 173 | server.close(); | ||
| 174 | }); | ||
| 175 | }; | ||
| 2 | 176 | ||
| 3 | import { collectRawWorkOSList, collectWorkOSList } from "./workos"; | 177 | const runAuthenticate = (sessionData: string, baseUrl: string) => { |
| 178 | Object.assign(env, { | ||
| 179 | WORKOS_API_KEY: API_KEY, | ||
| 180 | WORKOS_CLIENT_ID: CLIENT_ID, | ||
| 181 | WORKOS_COOKIE_PASSWORD: COOKIE_PASSWORD, | ||
| 182 | WORKOS_API_URL: baseUrl, | ||
| 183 | }); | ||
| 184 | |||
| 185 | return Effect.runPromise( | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const workos = yield* WorkOSClient; | ||
| 188 | return yield* workos.authenticateSealedSession(sessionData); | ||
| 189 | }).pipe(Effect.provide(WorkOSClient.Default)), | ||
| 190 | ); | ||
| 191 | }; | ||
| 192 | |||
| 193 | describe("authenticateSealedSession", () => { | ||
| 194 | it("validates a sealed session locally with the cached JWKS", async () => { | ||
| 195 | const keypair = await generateKeypair("k_valid"); | ||
| 196 | await withWorkOSStub(keypair, async (stub) => { | ||
| 197 | const token = await signAccessToken(keypair, { | ||
| 198 | organizationId: "org_test", | ||
| 199 | sessionId: "session_valid", | ||
| 200 | }); | ||
| 201 | const session = await sealSession(token); | ||
| 202 | |||
| 203 | const result = await runAuthenticate(session, stub.baseUrl); | ||
| 204 | |||
| 205 | expect(result).toEqual({ | ||
| 206 | userId: USER.id, | ||
| 207 | email: USER.email, | ||
| 208 | firstName: USER.firstName, | ||
| 209 | lastName: USER.lastName, | ||
| 210 | avatarUrl: USER.profilePictureUrl, | ||
| 211 | organizationId: "org_test", | ||
| 212 | sessionId: "session_valid", | ||
| 213 | refreshedSession: undefined, | ||
| 214 | }); | ||
| 215 | expect(stub.requests()).toEqual([ | ||
| 216 | { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, | ||
| 217 | ]); | ||
| 218 | }); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("reuses the module cached JWKS for a second sealed session", async () => { | ||
| 222 | const keypair = await generateKeypair("k_cached"); | ||
| 223 | await withWorkOSStub(keypair, async (stub) => { | ||
| 224 | const first = await sealSession( | ||
| 225 | await signAccessToken(keypair, { | ||
| 226 | sessionId: "session_first", | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | const second = await sealSession( | ||
| 230 | await signAccessToken(keypair, { | ||
| 231 | sessionId: "session_second", | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | |||
| 235 | await runAuthenticate(first, stub.baseUrl); | ||
| 236 | const afterFirst = stub.requests().length; | ||
| 237 | const result = await runAuthenticate(second, stub.baseUrl); | ||
| 238 | |||
| 239 | expect(afterFirst).toBe(1); | ||
| 240 | expect(result?.sessionId).toBe("session_second"); | ||
| 241 | expect(stub.requests()).toEqual([ | ||
| 242 | { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, | ||
| 243 | ]); | ||
| 244 | }); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("falls through to SDK refresh when the access token is expired", async () => { | ||
| 248 | const keypair = await generateKeypair("k_expired"); | ||
| 249 | await withWorkOSStub(keypair, async (stub) => { | ||
| 250 | const expiredAt = Math.floor(Date.now() / 1000) - 60; | ||
| 251 | const expired = await signAccessToken(keypair, { | ||
| 252 | expiresIn: expiredAt, | ||
| 253 | sessionId: "session_expired", | ||
| 254 | }); | ||
| 255 | const session = await sealSession(expired, "refresh_expired"); | ||
| 256 | |||
| 257 | const result = await runAuthenticate(session, stub.baseUrl); | ||
| 258 | |||
| 259 | expect(result?.sessionId).toBe("session_refreshed"); | ||
| 260 | expect(result?.refreshedSession).toEqual(expect.any(String)); | ||
| 261 | expect(stub.requests().map((request) => [request.method, request.path])).toEqual([ | ||
| 262 | ["GET", `/sso/jwks/${CLIENT_ID}`], | ||
| 263 | ["POST", "/user_management/authenticate"], | ||
| 264 | ]); | ||
| 265 | expect(stub.requests()[1]?.body).toMatchObject({ | ||
| 266 | grant_type: "refresh_token", | ||
| 267 | client_id: CLIENT_ID, | ||
| 268 | client_secret: API_KEY, | ||
| 269 | refresh_token: "refresh_expired", | ||
| 270 | organization_id: "org_test", | ||
| 271 | }); | ||
| 272 | }); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("returns null for garbage session data", async () => { | ||
| 276 | const keypair = await generateKeypair("k_garbage"); | ||
| 277 | await withWorkOSStub(keypair, async (stub) => { | ||
| 278 | const result = await runAuthenticate("not-a-sealed-session", stub.baseUrl); | ||
| 279 | |||
| 280 | expect(result).toBeNull(); | ||
| 281 | expect(stub.requests()).toEqual([]); | ||
| 282 | }); | ||
| 283 | }); | ||
| 284 | }); | ||
| 4 | 285 | ||
| 5 | describe("collectWorkOSList", () => { | 286 | describe("collectWorkOSList", () => { |
| 6 | it("collects memberships beyond the first WorkOS page", async () => { | 287 | it("collects memberships beyond the first WorkOS page", async () => { |
| @@ -3,13 +3,23 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | 5 | import { env } from "cloudflare:workers"; |
| 6 | import { Context, Data, Effect, Layer, Option, Schema } from "effect"; | 6 | import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"; |
| 7 | import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; | 7 | import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; |
| 8 | import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; | ||
| 9 | import { decodeJwt, jwtVerify } from "jose"; | ||
| 10 | import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors"; | ||
| 8 | import { parseCookie } from "./cookies"; | 11 | import { parseCookie } from "./cookies"; |
| 9 | import { tryPromiseService, withServiceLogging, workosErrorFromFailure } from "./errors"; | 12 | import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache"; |
| 13 | import { | ||
| 14 | ServiceAdapterError, | ||
| 15 | tryPromiseService, | ||
| 16 | withServiceLogging, | ||
| 17 | workosErrorFromFailure, | ||
| 18 | } from "./errors"; | ||
| 10 | 19 | ||
| 11 | const COOKIE_NAME = "wos-session"; | 20 | const COOKIE_NAME = "wos-session"; |
| 12 | const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters"; | 21 | const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters"; |
| 22 | const WORKOS_SEAL_VERSION_DELIMITER = "~"; | ||
| 13 | 23 | ||
| 14 | type RawWorkOS = WorkOS & { | 24 | type RawWorkOS = WorkOS & { |
| 15 | readonly get: ( | 25 | readonly get: ( |
| @@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines | |||
| 59 | 69 | ||
| 60 | const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); | 70 | const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); |
| 61 | 71 | ||
| 72 | const SealedSessionUser = Schema.Struct({ | ||
| 73 | id: Schema.String, | ||
| 74 | email: Schema.String, | ||
| 75 | firstName: Schema.NullOr(Schema.String), | ||
| 76 | lastName: Schema.NullOr(Schema.String), | ||
| 77 | profilePictureUrl: Schema.NullOr(Schema.String), | ||
| 78 | }); | ||
| 79 | |||
| 80 | const SealedSessionPayload = Schema.Struct({ | ||
| 81 | accessToken: Schema.String, | ||
| 82 | refreshToken: Schema.String, | ||
| 83 | user: SealedSessionUser, | ||
| 84 | authenticationMethod: Schema.optional(Schema.Unknown), | ||
| 85 | impersonator: Schema.optional(Schema.Unknown), | ||
| 86 | }); | ||
| 87 | |||
| 88 | const decodeSealedSessionPayload = Schema.decodeUnknownOption(SealedSessionPayload); | ||
| 89 | |||
| 90 | const JwtClaims = Schema.Struct({ | ||
| 91 | sid: Schema.String, | ||
| 92 | org_id: Schema.optional(Schema.String), | ||
| 93 | }); | ||
| 94 | |||
| 95 | const decodeJwtClaims = Schema.decodeUnknownOption(JwtClaims); | ||
| 96 | |||
| 97 | const LegacySealedSessionPayload = Schema.Struct({ | ||
| 98 | persistent: Schema.Unknown, | ||
| 99 | }); | ||
| 100 | |||
| 101 | const decodeLegacySealedSessionPayload = Schema.decodeUnknownOption(LegacySealedSessionPayload); | ||
| 102 | |||
| 103 | type SealedSessionPayload = typeof SealedSessionPayload.Type; | ||
| 104 | |||
| 105 | type LocalSessionVerification = | ||
| 106 | | { | ||
| 107 | readonly _tag: "Valid"; | ||
| 108 | readonly session: SealedSessionPayload; | ||
| 109 | readonly organizationId: string | undefined; | ||
| 110 | readonly sessionId: string; | ||
| 111 | } | ||
| 112 | | { readonly _tag: "Refresh" } | ||
| 113 | | { readonly _tag: "InvalidCookie" }; | ||
| 114 | |||
| 115 | class LocalSessionCookieError extends Data.TaggedError("LocalSessionCookieError")<{ | ||
| 116 | readonly cause: unknown; | ||
| 117 | }> {} | ||
| 118 | |||
| 119 | const isLocalSessionValid = Predicate.isTagged("Valid") as ( | ||
| 120 | value: LocalSessionVerification, | ||
| 121 | ) => value is Extract<LocalSessionVerification, { readonly _tag: "Valid" }>; | ||
| 122 | |||
| 123 | const isLocalSessionInvalidCookie = Predicate.isTagged("InvalidCookie") as ( | ||
| 124 | value: LocalSessionVerification, | ||
| 125 | ) => value is Extract<LocalSessionVerification, { readonly _tag: "InvalidCookie" }>; | ||
| 126 | |||
| 127 | const ErrorWithCode = Schema.Struct({ code: Schema.String }); | ||
| 128 | const isErrorWithCode = Schema.is(ErrorWithCode); | ||
| 129 | |||
| 130 | const isJwtVerificationFailure = (cause: unknown): boolean => | ||
| 131 | isErrorWithCode(cause) && | ||
| 132 | (cause.code.startsWith("ERR_JWT_") || | ||
| 133 | cause.code.startsWith("ERR_JWS_") || | ||
| 134 | cause.code === JWKSNoMatchingKey.code); | ||
| 135 | |||
| 136 | const isJwksSystemFailure = (cause: unknown): boolean => | ||
| 137 | isErrorWithCode(cause) && (cause.code === JWKSTimeout.code || cause.code === JWKSInvalid.code); | ||
| 138 | |||
| 139 | const parseWorkOSSeal = ( | ||
| 140 | seal: string, | ||
| 141 | ): { readonly sealWithoutVersion: string; readonly tokenVersion: number | null } => { | ||
| 142 | const [sealWithoutVersion = "", tokenVersionAsString] = seal.split(WORKOS_SEAL_VERSION_DELIMITER); | ||
| 143 | return { | ||
| 144 | sealWithoutVersion, | ||
| 145 | tokenVersion: | ||
| 146 | tokenVersionAsString === undefined ? null : Number.parseInt(tokenVersionAsString, 10), | ||
| 147 | }; | ||
| 148 | }; | ||
| 149 | |||
| 150 | const unsealWorkOSSession = async ( | ||
| 151 | sessionData: string, | ||
| 152 | cookiePassword: string, | ||
| 153 | ): Promise<unknown> => { | ||
| 154 | const { sealWithoutVersion, tokenVersion } = parseWorkOSSeal(sessionData); | ||
| 155 | const data = | ||
| 156 | (await unsealIron(sealWithoutVersion, { 1: cookiePassword }, { ...ironDefaults, ttl: 0 })) ?? | ||
| 157 | {}; | ||
| 158 | // Mirrors the SDK's unsealData version handling: current (v2) seals hold the | ||
| 159 | // payload directly, OTHER versioned seals nest it under `persistent`, and an | ||
| 160 | // unversioned seal is the payload itself. | ||
| 161 | if (tokenVersion === 2 || tokenVersion === null) return data; | ||
| 162 | return Option.match(decodeLegacySealedSessionPayload(data), { | ||
| 163 | onNone: () => data, | ||
| 164 | onSome: (legacy) => legacy.persistent, | ||
| 165 | }); | ||
| 166 | }; | ||
| 167 | |||
| 168 | const getWorkOSSessionJwks = (() => { | ||
| 169 | const resolvers = new Map<string, CachedRemoteJWKSet>(); | ||
| 170 | return (url: URL): CachedRemoteJWKSet => { | ||
| 171 | const key = url.toString(); | ||
| 172 | const existing = resolvers.get(key); | ||
| 173 | if (existing) return existing; | ||
| 174 | const created = createCachedRemoteJWKSet(url); | ||
| 175 | resolvers.set(key, created); | ||
| 176 | return created; | ||
| 177 | }; | ||
| 178 | })(); | ||
| 179 | |||
| 180 | const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) => | ||
| 181 | Effect.tryPromise({ | ||
| 182 | try: () => jwtVerify(accessToken, jwks), | ||
| 183 | catch: (cause) => new ServiceAdapterError({ cause }), | ||
| 184 | }); | ||
| 185 | |||
| 186 | const verifyJwtWithRefreshRetry = ( | ||
| 187 | accessToken: string, | ||
| 188 | jwks: CachedRemoteJWKSet, | ||
| 189 | ): Effect.Effect<boolean, ServiceAdapterError> => | ||
| 190 | verifyJwtOnce(accessToken, jwks).pipe( | ||
| 191 | Effect.as(true), | ||
| 192 | Effect.catchTag("ServiceAdapterError", (error) => { | ||
| 193 | if (isErrorWithCode(error.cause) && error.cause.code === JWKSNoMatchingKey.code) { | ||
| 194 | return Effect.sync(() => jwks.forceRefresh()).pipe( | ||
| 195 | Effect.flatMap(() => verifyJwtOnce(accessToken, jwks)), | ||
| 196 | Effect.as(true), | ||
| 197 | Effect.catchTag("ServiceAdapterError", (retryError) => | ||
| 198 | isJwtVerificationFailure(retryError.cause) | ||
| 199 | ? Effect.succeed(false) | ||
| 200 | : Effect.fail(retryError), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | if (isJwtVerificationFailure(error.cause)) return Effect.succeed(false); | ||
| 205 | if (isJwksSystemFailure(error.cause)) return Effect.fail(error); | ||
| 206 | return Effect.fail(error); | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | |||
| 210 | const verifySealedSessionLocally = ( | ||
| 211 | sessionData: string, | ||
| 212 | cookiePassword: string, | ||
| 213 | jwks: CachedRemoteJWKSet, | ||
| 214 | ): Effect.Effect<LocalSessionVerification, ServiceAdapterError> => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | // Phase timings, not just child spans. `local_verify` is a leaf in | ||
| 217 | // production traces, so a ~3.3s verify has nothing under it to blame — | ||
| 218 | // and it stayed 3.3s after the JWKS fetch was eliminated entirely | ||
| 219 | // (jwks.fetch_count == 0), so the cost is one of the phases below. Under | ||
| 220 | // workerd `Date.now()` only advances at I/O boundaries, which is exactly | ||
| 221 | // what makes a raw span duration misleading here: recording each phase | ||
| 222 | // explicitly says which await the wall-clock actually crossed. | ||
| 223 | const verifyStartedAt = Date.now(); | ||
| 224 | |||
| 225 | const unsealStartedAt = Date.now(); | ||
| 226 | const unsealed = yield* Effect.tryPromise({ | ||
| 227 | try: () => unsealWorkOSSession(sessionData, cookiePassword), | ||
| 228 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 229 | }).pipe( | ||
| 230 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), | ||
| 231 | Effect.withSpan("workos.session.unseal"), | ||
| 232 | ); | ||
| 233 | const unsealMs = Date.now() - unsealStartedAt; | ||
| 234 | yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs }); | ||
| 235 | if (!unsealed) return { _tag: "InvalidCookie" }; | ||
| 236 | |||
| 237 | const decodeStartedAt = Date.now(); | ||
| 238 | const session = Option.match(decodeSealedSessionPayload(unsealed), { | ||
| 239 | onNone: (): SealedSessionPayload | null => null, | ||
| 240 | onSome: (payload) => payload, | ||
| 241 | }); | ||
| 242 | yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt }); | ||
| 243 | if (!session) return { _tag: "InvalidCookie" }; | ||
| 244 | |||
| 245 | // Snapshot the JWKS cache around the verify so the `local_verify` span | ||
| 246 | // says whether THIS verify was a warm-cache signature check or paid for a | ||
| 247 | // live upstream JWKS fetch. The Aug 2026 latency regression was the cache | ||
| 248 | // silently missing on most verifies, and no span attribute distinguished | ||
| 249 | // the two paths. | ||
| 250 | const jwksBefore = jwks.inspect(); | ||
| 251 | // Entry-state annotation goes on BEFORE the verify so a failing verify | ||
| 252 | // (the case worth debugging) still records whether the cache was warm. | ||
| 253 | yield* Effect.annotateCurrentSpan({ | ||
| 254 | "jwks.cache_populated_at_start": jwksBefore.hasJwks, | ||
| 255 | ...(jwksBefore.fetchedAt === null | ||
| 256 | ? {} | ||
| 257 | : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }), | ||
| 258 | }); | ||
| 259 | const jwtStartedAt = Date.now(); | ||
| 260 | const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe( | ||
| 261 | Effect.withSpan("workos.session.jwt_verify"), | ||
| 262 | Effect.onExit(() => { | ||
| 263 | const jwksAfter = jwks.inspect(); | ||
| 264 | const finishedAt = Date.now(); | ||
| 265 | return Effect.annotateCurrentSpan({ | ||
| 266 | "verify.jwt_ms": finishedAt - jwtStartedAt, | ||
| 267 | "verify.total_ms": finishedAt - verifyStartedAt, | ||
| 268 | // Blocking, not total: under stale-while-revalidate a background | ||
| 269 | // refresh moves `fetchCount` without costing this verify anything. | ||
| 270 | // Attribute latency to what the caller actually waited on. | ||
| 271 | "jwks.fetched_during_verify": | ||
| 272 | jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount, | ||
| 273 | "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount, | ||
| 274 | "jwks.fetch_count": jwksAfter.fetchCount, | ||
| 275 | "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount, | ||
| 276 | "jwks.fetch_failure_count": jwksAfter.fetchFailureCount, | ||
| 277 | ...(jwksAfter.lastFetchDurationMs === null | ||
| 278 | ? {} | ||
| 279 | : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }), | ||
| 280 | // Splits the ~3.4s that sits inside jwt_verify with zero upstream | ||
| 281 | // fetches: the cross-isolate store read (I/O) vs WebCrypto key | ||
| 282 | // import vs the signature check itself. | ||
| 283 | ...(jwksAfter.lastStoreReadMs === null | ||
| 284 | ? {} | ||
| 285 | : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }), | ||
| 286 | ...(jwksAfter.lastResolveMs === null | ||
| 287 | ? {} | ||
| 288 | : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }), | ||
| 289 | }); | ||
| 290 | }), | ||
| 291 | ); | ||
| 292 | if (!verified) return { _tag: "Refresh" }; | ||
| 293 | |||
| 294 | const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken))); | ||
| 295 | if (!claims) return { _tag: "Refresh" }; | ||
| 296 | |||
| 297 | return { | ||
| 298 | _tag: "Valid", | ||
| 299 | session, | ||
| 300 | organizationId: claims.org_id, | ||
| 301 | sessionId: claims.sid, | ||
| 302 | }; | ||
| 303 | }); | ||
| 304 | |||
| 62 | const completedListMetadata = { | 305 | const completedListMetadata = { |
| 63 | before: null, | 306 | before: null, |
| 64 | after: null, | 307 | after: null, |
| @@ -150,14 +393,18 @@ const make = Effect.gen(function* () { · 85 unmodified lines | |||
| 150 | clientId, | 393 | clientId, |
| 151 | ...workosApiUrlOptions(env.WORKOS_API_URL), | 394 | ...workosApiUrlOptions(env.WORKOS_API_URL), |
| 152 | }); | 395 | }); |
| 396 | const sessionJwks = getWorkOSSessionJwks(new URL(workos.userManagement.getJwksUrl(clientId))); | ||
| 153 | 397 | ||
| 154 | // The public `WorkOSError` carries the upstream HTTP status when the SDK | 398 | // The public `WorkOSError` carries the upstream HTTP status when the SDK |
| 155 | // exception had one (all its typed exceptions do), so consumers can tell a | 399 | // exception had one (all its typed exceptions do), so consumers can tell a |
| 156 | // definitive WorkOS denial (401/403/404 — fail closed) from a transient | 400 | // definitive WorkOS denial (401/403/404 — fail closed) from a transient |
| 157 | // failure (429/5xx/network — retryable). | 401 | // failure (429/5xx/network — retryable). |
| 158 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => | 402 | // `op` names the SDK call (mirroring its `namespace.method` path) so every |
| 403 | // span reads `workos.<operation>` instead of one undifferentiated "workos" | ||
| 404 | // bucket, and failures log which call actually failed. | ||
| 405 | const use = <A>(op: string, fn: (wos: WorkOS) => Promise<A>) => | ||
| 159 | withServiceLogging( | 406 | withServiceLogging( |
| 160 | "workos", | 407 | `workos.${op}`, |
| 161 | workosErrorFromFailure, | 408 | workosErrorFromFailure, |
| 162 | tryPromiseService(() => fn(workos)), | 409 | tryPromiseService(() => fn(workos)), |
| 163 | ); | 410 | ); |
| @@ -171,25 +418,29 @@ const make = Effect.gen(function* () { · 7 unmodified lines | |||
| 171 | cookiePassword, | 418 | cookiePassword, |
| 172 | }); | 419 | }); |
| 173 | 420 | ||
| 174 | const result = yield* use(() => session.authenticate()); | 421 | const local = yield* withServiceLogging( |
| 422 | "workos.session.local_verify", | ||
| 423 | workosErrorFromFailure, | ||
| 424 | verifySealedSessionLocally(sessionData, cookiePassword, sessionJwks), | ||
| 425 | ); | ||
| 175 | 426 | ||
| 176 | if (result.authenticated) { | 427 | if (isLocalSessionValid(local)) { |
| 177 | return { | 428 | return { |
| 178 | userId: result.user.id, | 429 | userId: local.session.user.id, |
| 179 | email: result.user.email, | 430 | email: local.session.user.email, |
| 180 | firstName: result.user.firstName, | 431 | firstName: local.session.user.firstName, |
| 181 | lastName: result.user.lastName, | 432 | lastName: local.session.user.lastName, |
| 182 | avatarUrl: result.user.profilePictureUrl, | 433 | avatarUrl: local.session.user.profilePictureUrl, |
| 183 | organizationId: result.organizationId, | 434 | organizationId: local.organizationId, |
| 184 | sessionId: result.sessionId, | 435 | sessionId: local.sessionId, |
| 185 | refreshedSession: undefined as string | undefined, | 436 | refreshedSession: undefined as string | undefined, |
| 186 | }; | 437 | }; |
| 187 | } | 438 | } |
| 188 | 439 | ||
| 189 | if (result.reason === "no_session_cookie_provided") return null; | 440 | if (isLocalSessionInvalidCookie(local)) return null; |
| 190 | 441 | ||
| 191 | // Try refreshing | 442 | // Try refreshing |
| 192 | const refreshed = yield* use(() => session.refresh()).pipe( | 443 | const refreshed = yield* use("session.refresh", () => session.refresh()).pipe( |
| 193 | Effect.orElseSucceed(() => ({ authenticated: false as const })), | 444 | Effect.orElseSucceed(() => ({ authenticated: false as const })), |
| 194 | ); | 445 | ); |
| 195 | 446 | ||
| @@ -218,7 +469,7 @@ const make = Effect.gen(function* () { · 22 unmodified lines | |||
| 218 | }), | 469 | }), |
| 219 | 470 | ||
| 220 | authenticateWithCode: (code: string) => | 471 | authenticateWithCode: (code: string) => |
| 221 | use((wos) => | 472 | use("userManagement.authenticateWithCode", (wos) => |
| 222 | wos.userManagement.authenticateWithCode({ | 473 | wos.userManagement.authenticateWithCode({ |
| 223 | code, | 474 | code, |
| 224 | clientId, | 475 | clientId, |
| @@ -228,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 228 | 479 | ||
| 229 | /** Create a new organization in WorkOS. */ | 480 | /** Create a new organization in WorkOS. */ |
| 230 | createOrganization: (name: string) => | 481 | createOrganization: (name: string) => |
| 231 | use((wos) => wos.organizations.createOrganization({ name })), | 482 | use("organizations.createOrganization", (wos) => |
| 483 | wos.organizations.createOrganization({ name }), | ||
| 484 | ), | ||
| 232 | 485 | ||
| 233 | /** Add a user to an organization. */ | 486 | /** Add a user to an organization. */ |
| 234 | createMembership: (organizationId: string, userId: string, roleSlug?: string) => | 487 | createMembership: (organizationId: string, userId: string, roleSlug?: string) => |
| 235 | use((wos) => | 488 | use("userManagement.createOrganizationMembership", (wos) => |
| 236 | wos.userManagement.createOrganizationMembership({ | 489 | wos.userManagement.createOrganizationMembership({ |
| 237 | organizationId, | 490 | organizationId, |
| 238 | userId, | 491 | userId, |
| @@ -242,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 242 | 495 | ||
| 243 | /** List organization memberships for a user. */ | 496 | /** List organization memberships for a user. */ |
| 244 | listUserMemberships: (userId: string) => | 497 | listUserMemberships: (userId: string) => |
| 245 | use(async (wos) => | 498 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 246 | collectWorkOSList( | 499 | collectWorkOSList( |
| 247 | await wos.userManagement.listOrganizationMemberships({ | 500 | await wos.userManagement.listOrganizationMemberships({ |
| 248 | userId, | 501 | userId, |
| @@ -261,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines | |||
| 261 | sessionData, | 514 | sessionData, |
| 262 | cookiePassword, | 515 | cookiePassword, |
| 263 | }); | 516 | }); |
| 264 | const refreshed = yield* use(() => | 517 | const refreshed = yield* use("session.refresh", () => |
| 265 | session.refresh(organizationId ? { organizationId } : undefined), | 518 | session.refresh(organizationId ? { organizationId } : undefined), |
| 266 | ); | 519 | ); |
| 267 | if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; | 520 | if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; |
| 268 | return refreshed.sealedSession ?? null; | 521 | return refreshed.sealedSession ?? null; |
| 269 | }), | 522 | }), |
| 270 | 523 | ||
| 524 | /** | ||
| 525 | * The WorkOS logout URL for a sealed session, or null when the cookie | ||
| 526 | * doesn't unseal or carries no session id (fail-open: the caller clears | ||
| 527 | * local cookies and redirects home instead). The access token is decoded | ||
| 528 | * WITHOUT signature verification, deliberately — WorkOS's documented | ||
| 529 | * logout flow reads `sid` from a possibly-expired token, and logout must | ||
| 530 | * still work for a stale session. (The SDK's `session.getLogoutUrl()` is | ||
| 531 | * deliberately NOT used: it re-authenticates first and throws on an | ||
| 532 | * expired token.) Pure URL construction, no network call; hitting the | ||
| 533 | * URL is the browser's navigation, which is what ends the AuthKit | ||
| 534 | * session upstream. | ||
| 535 | */ | ||
| 536 | logoutUrl: (sessionData: string, returnTo?: string) => | ||
| 537 | Effect.gen(function* () { | ||
| 538 | const unsealed = yield* Effect.tryPromise({ | ||
| 539 | try: () => unsealWorkOSSession(sessionData, cookiePassword), | ||
| 540 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 541 | }).pipe( | ||
| 542 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), | ||
| 543 | ); | ||
| 544 | if (!unsealed) return null; | ||
| 545 | |||
| 546 | const session = Option.getOrNull(decodeSealedSessionPayload(unsealed)); | ||
| 547 | if (!session) return null; | ||
| 548 | |||
| 549 | const claims = yield* Effect.try({ | ||
| 550 | try: () => decodeJwt(session.accessToken), | ||
| 551 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 552 | }).pipe( | ||
| 553 | Effect.map((jwt) => Option.getOrNull(decodeJwtClaims(jwt))), | ||
| 554 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null)), | ||
| 555 | ); | ||
| 556 | if (!claims) return null; | ||
| 557 | |||
| 558 | return workos.userManagement.getLogoutUrl({ | ||
| 559 | sessionId: claims.sid, | ||
| 560 | ...(returnTo ? { returnTo } : {}), | ||
| 561 | }); | ||
| 562 | }), | ||
| 563 | |||
| 271 | /** | 564 | /** |
| 272 | * Authenticate a sealed session string. Returns the user info plus | 565 | * Authenticate a sealed session string. Returns the user info plus |
| 273 | * any refreshed session that needs to be set on the response. | 566 | * any refreshed session that needs to be set on the response. |
| @@ -290,10 +583,13 @@ const make = Effect.gen(function* () { · 16 unmodified lines | |||
| 290 | * auth/api-keys.ts. | 583 | * auth/api-keys.ts. |
| 291 | */ | 584 | */ |
| 292 | validateApiKey: (value: string) => | 585 | validateApiKey: (value: string) => |
| 293 | use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>), | 586 | use( |
| 587 | "apiKeys.validateApiKey", | ||
| 588 | (wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>, | ||
| 589 | ), | ||
| 294 | 590 | ||
| 295 | listUserApiKeys: (userId: string, organizationId: string) => | 591 | listUserApiKeys: (userId: string, organizationId: string) => |
| 296 | use(async (wos) => { | 592 | use("userManagement.listUserApiKeys", async (wos) => { |
| 297 | const raw = wos as RawWorkOS; | 593 | const raw = wos as RawWorkOS; |
| 298 | return collectRawWorkOSList(async (after) => { | 594 | return collectRawWorkOSList(async (after) => { |
| 299 | const response = await raw.get(`/user_management/users/${userId}/api_keys`, { | 595 | const response = await raw.get(`/user_management/users/${userId}/api_keys`, { |
| @@ -308,7 +604,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 308 | }), | 604 | }), |
| 309 | 605 | ||
| 310 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => | 606 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => |
| 311 | use(async (wos) => { | 607 | use("userManagement.createUserApiKey", async (wos) => { |
| 312 | const raw = wos as RawWorkOS; | 608 | const raw = wos as RawWorkOS; |
| 313 | const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { | 609 | const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { |
| 314 | name: params.name, | 610 | name: params.name, |
| @@ -317,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 317 | return response.data; | 613 | return response.data; |
| 318 | }), | 614 | }), |
| 319 | 615 | ||
| 320 | deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)), | 616 | /** |
| 617 | * List the ORGANIZATION-owned api keys of an org. Unlike the user-key | ||
| 618 | * paths above, org keys are first-class in the installed SDK | ||
| 619 | * (`organizations.listOrganizationApiKeys`), so no raw escape hatch is | ||
| 620 | * needed. Returned unknown and decoded in `auth/api-keys.ts` like every | ||
| 621 | * other key response. | ||
| 622 | */ | ||
| 623 | listOrgApiKeys: (organizationId: string) => | ||
| 624 | use("organizations.listOrganizationApiKeys", async (wos) => | ||
| 625 | collectWorkOSList(await wos.organizations.listOrganizationApiKeys({ organizationId })), | ||
| 626 | ), | ||
| 627 | |||
| 628 | /** | ||
| 629 | * Mint an ORGANIZATION-owned api key: the credential that resolves to the | ||
| 630 | * read-only platform view (`PlatformAuth`) instead of an acting member. | ||
| 631 | * Privileged — the caller must be an admin of the org, which is enforced at | ||
| 632 | * the account-provider boundary, not here. | ||
| 633 | */ | ||
| 634 | createOrgApiKey: (params: { organizationId: string; name: string }) => | ||
| 635 | use( | ||
| 636 | "organizations.createOrganizationApiKey", | ||
| 637 | (wos) => | ||
| 638 | wos.organizations.createOrganizationApiKey({ | ||
| 639 | organizationId: params.organizationId, | ||
| 640 | name: params.name, | ||
| 641 | }) as Promise<unknown>, | ||
| 642 | ), | ||
| 643 | |||
| 644 | deleteApiKey: (id: string) => | ||
| 645 | use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), | ||
| 321 | 646 | ||
| 322 | /** List organization memberships with user details. */ | 647 | /** List organization memberships with user details. */ |
| 323 | listOrgMembers: (organizationId: string) => | 648 | listOrgMembers: (organizationId: string) => |
| 324 | use(async (wos) => | 649 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 325 | collectWorkOSList( | 650 | collectWorkOSList( |
| 326 | await wos.userManagement.listOrganizationMemberships({ | 651 | await wos.userManagement.listOrganizationMemberships({ |
| 327 | organizationId, | 652 | organizationId, |
| @@ -332,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 332 | 657 | ||
| 333 | /** Get a user's membership in an organization. */ | 658 | /** Get a user's membership in an organization. */ |
| 334 | getUserOrgMembership: (organizationId: string, userId: string) => | 659 | getUserOrgMembership: (organizationId: string, userId: string) => |
| 335 | use(async (wos) => { | 660 | use("userManagement.listOrganizationMemberships", async (wos) => { |
| 336 | const response = await wos.userManagement.listOrganizationMemberships({ | 661 | const response = await wos.userManagement.listOrganizationMemberships({ |
| 337 | organizationId, | 662 | organizationId, |
| 338 | userId, | 663 | userId, |
| @@ -342,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 342 | }), | 667 | }), |
| 343 | 668 | ||
| 344 | /** Get a user by ID. */ | 669 | /** Get a user by ID. */ |
| 345 | getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), | 670 | getUser: (userId: string) => |
| 671 | use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), | ||
| 672 | |||
| 673 | /** List users matching an email within one organization. */ | ||
| 674 | listUsers: (params: { email: string; organizationId: string }) => | ||
| 675 | use("userManagement.listUsers", async (wos) => | ||
| 676 | collectWorkOSList( | ||
| 677 | await wos.userManagement.listUsers({ | ||
| 678 | email: params.email, | ||
| 679 | organizationId: params.organizationId, | ||
| 680 | }), | ||
| 681 | ), | ||
| 682 | ), | ||
| 346 | 683 | ||
| 347 | /** Send an organization invitation. */ | 684 | /** Send an organization invitation. */ |
| 348 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => | 685 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => |
| 349 | use((wos) => | 686 | use("userManagement.sendInvitation", (wos) => |
| 350 | wos.userManagement.sendInvitation({ | 687 | wos.userManagement.sendInvitation({ |
| 351 | email: params.email, | 688 | email: params.email, |
| 352 | organizationId: params.organizationId, | 689 | organizationId: params.organizationId, |
| @@ -360,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines | |||
| 360 | * API level, so we filter after. | 697 | * API level, so we filter after. |
| 361 | */ | 698 | */ |
| 362 | listPendingInvitations: (organizationId: string) => | 699 | listPendingInvitations: (organizationId: string) => |
| 363 | use(async (wos) => | 700 | use("userManagement.listInvitations", async (wos) => |
| 364 | collectWorkOSList( | 701 | collectWorkOSList( |
| 365 | await wos.userManagement.listInvitations({ | 702 | await wos.userManagement.listInvitations({ |
| 366 | organizationId, | 703 | organizationId, |
| @@ -375,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 375 | 712 | ||
| 376 | /** List invitations for an email address (across all orgs). */ | 713 | /** List invitations for an email address (across all orgs). */ |
| 377 | listInvitationsByEmail: (email: string) => | 714 | listInvitationsByEmail: (email: string) => |
| 378 | use(async (wos) => | 715 | use("userManagement.listInvitations", async (wos) => |
| 379 | collectWorkOSList( | 716 | collectWorkOSList( |
| 380 | await wos.userManagement.listInvitations({ | 717 | await wos.userManagement.listInvitations({ |
| 381 | email, | 718 | email, |
| @@ -385,19 +722,25 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 385 | 722 | ||
| 386 | /** Accept an invitation; returns the (now accepted) invitation. */ | 723 | /** Accept an invitation; returns the (now accepted) invitation. */ |
| 387 | acceptInvitation: (invitationId: string) => | 724 | acceptInvitation: (invitationId: string) => |
| 388 | use((wos) => wos.userManagement.acceptInvitation(invitationId)), | 725 | use("userManagement.acceptInvitation", (wos) => |
| 726 | wos.userManagement.acceptInvitation(invitationId), | ||
| 727 | ), | ||
| 389 | 728 | ||
| 390 | /** Remove an organization membership. */ | 729 | /** Remove an organization membership. */ |
| 391 | deleteOrgMembership: (membershipId: string) => | 730 | deleteOrgMembership: (membershipId: string) => |
| 392 | use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), | 731 | use("userManagement.deleteOrganizationMembership", (wos) => |
| 732 | wos.userManagement.deleteOrganizationMembership(membershipId), | ||
| 733 | ), | ||
| 393 | 734 | ||
| 394 | /** Get the role for a membership. */ | 735 | /** Get the role for a membership. */ |
| 395 | getOrgMembership: (membershipId: string) => | 736 | getOrgMembership: (membershipId: string) => |
| 396 | use((wos) => wos.userManagement.getOrganizationMembership(membershipId)), | 737 | use("userManagement.getOrganizationMembership", (wos) => |
| 738 | wos.userManagement.getOrganizationMembership(membershipId), | ||
| 739 | ), | ||
| 397 | 740 | ||
| 398 | /** Update a membership's role. */ | 741 | /** Update a membership's role. */ |
| 399 | updateOrgMembershipRole: (membershipId: string, roleSlug: string) => | 742 | updateOrgMembershipRole: (membershipId: string, roleSlug: string) => |
| 400 | use((wos) => | 743 | use("userManagement.updateOrganizationMembership", (wos) => |
| 401 | wos.userManagement.updateOrganizationMembership(membershipId, { | 744 | wos.userManagement.updateOrganizationMembership(membershipId, { |
| 402 | roleSlug, | 745 | roleSlug, |
| 403 | }), | 746 | }), |
| @@ -405,15 +748,19 @@ const make = Effect.gen(function* () { · 1 unmodified line | |||
| 405 | 748 | ||
| 406 | /** List available roles for an organization. */ | 749 | /** List available roles for an organization. */ |
| 407 | listOrgRoles: (organizationId: string) => | 750 | listOrgRoles: (organizationId: string) => |
| 408 | use((wos) => wos.organizations.listOrganizationRoles({ organizationId })), | 751 | use("organizations.listOrganizationRoles", (wos) => |
| 752 | wos.organizations.listOrganizationRoles({ organizationId }), | ||
| 753 | ), | ||
| 409 | 754 | ||
| 410 | /** Get an organization (includes domains). */ | 755 | /** Get an organization (includes domains). */ |
| 411 | getOrganization: (organizationId: string) => | 756 | getOrganization: (organizationId: string) => |
| 412 | use((wos) => wos.organizations.getOrganization(organizationId)), | 757 | use("organizations.getOrganization", (wos) => |
| 758 | wos.organizations.getOrganization(organizationId), | ||
| 759 | ), | ||
| 413 | 760 | ||
| 414 | /** Update an organization. */ | 761 | /** Update an organization. */ |
| 415 | updateOrganization: (organizationId: string, name: string) => | 762 | updateOrganization: (organizationId: string, name: string) => |
| 416 | use((wos) => | 763 | use("organizations.updateOrganization", (wos) => |
| 417 | wos.organizations.updateOrganization({ | 764 | wos.organizations.updateOrganization({ |
| 418 | organization: organizationId, | 765 | organization: organizationId, |
| 419 | name, | 766 | name, |
| @@ -425,11 +772,13 @@ const make = Effect.gen(function* () { · 5 unmodified lines | |||
| 425 | * invitations, and domains go with it, so every member loses access. | 772 | * invitations, and domains go with it, so every member loses access. |
| 426 | */ | 773 | */ |
| 427 | deleteOrganization: (organizationId: string) => | 774 | deleteOrganization: (organizationId: string) => |
| 428 | use((wos) => wos.organizations.deleteOrganization(organizationId)), | 775 | use("organizations.deleteOrganization", (wos) => |
| 776 | wos.organizations.deleteOrganization(organizationId), | ||
| 777 | ), | ||
| 429 | 778 | ||
| 430 | /** Generate an Admin Portal link for domain verification. */ | 779 | /** Generate an Admin Portal link for domain verification. */ |
| 431 | generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => | 780 | generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => |
| 432 | use((wos) => | 781 | use("portal.generateLink", (wos) => |
| 433 | wos.portal.generateLink({ | 782 | wos.portal.generateLink({ |
| 434 | organization: organizationId, | 783 | organization: organizationId, |
| 435 | intent: GeneratePortalLinkIntent.DomainVerification, | 784 | intent: GeneratePortalLinkIntent.DomainVerification, |
| @@ -439,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 439 | 788 | ||
| 440 | /** Get a domain by ID. */ | 789 | /** Get a domain by ID. */ |
| 441 | getOrganizationDomain: (domainId: string) => | 790 | getOrganizationDomain: (domainId: string) => |
| 442 | use((wos) => wos.organizationDomains.get(domainId)), | 791 | use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)), |
| 443 | 792 | ||
| 444 | /** Delete a domain claim. */ | 793 | /** Delete a domain claim. */ |
| 445 | deleteOrganizationDomain: (domainId: string) => | 794 | deleteOrganizationDomain: (domainId: string) => |
| 446 | use((wos) => wos.organizationDomains.delete(domainId)), | 795 | use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)), |
| 447 | }; | 796 | }; |
| 448 | }); | 797 | }); |
| 449 | 798 | ||
| @@ -452,9 +801,10 @@ export type WorkOSClientService = Effect.Success<typeof make>; · 2 unmodified lines | |||
| 452 | export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()( | 801 | export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()( |
| 453 | "@executor-js/cloud/WorkOSClient", | 802 | "@executor-js/cloud/WorkOSClient", |
| 454 | ) { | 803 | ) { |
| 455 | static Default = Layer.effect(this)(make).pipe( | 804 | // Deliberately unspanned: client construction is synchronous and ran per |
| 456 | Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), | 805 | // layer build, which produced one of the highest-volume zero-duration span |
| 457 | ); | 806 | // names in the whole trace corpus while telling us nothing. |
| 807 | static Default = Layer.effect(this)(make); | ||
| 458 | } | 808 | } |
| 459 | 809 | ||
| 460 | // The boot-scoped WorkOS client root — the one neutral service the stateless | 810 | // The boot-scoped WorkOS client root — the one neutral service the stateless |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | // merge of cloud + executor schemas. If ANY call site builds its own | 2 | // merge of cloud + executor schemas. If ANY call site builds its own |
| 3 | // `{ schema }` without spreading both, `db._.fullSchema` comes back | 3 | // `{ schema }` without spreading both, `db._.fullSchema` comes back |
| 4 | // missing tables and the drizzle adapter throws | 4 | // missing tables and the drizzle adapter throws |
| 5 | // `unknown model "source"` at the first request that touches scoped data. | 5 | // `unknown model "integration"` at the first request that touches scoped data. |
| 6 | // | 6 | // |
| 7 | // We hit this in prod (MCP endpoint) when `mcp-session.ts` built | 7 | // We hit this in prod (MCP endpoint) when `mcp-session.ts` built |
| 8 | // `combinedSchema = { ...cloudSchema }` and forgot to spread the executor | 8 | // `combinedSchema = { ...cloudSchema }` and forgot to spread the executor |
| @@ -56,7 +56,7 @@ describe("combinedSchema", () => { · 47 unmodified lines | |||
| 56 | } | 56 | } |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | // Executor-schema drives the scope-sharded tables (source/tool/etc). If | 59 | // Executor-schema drives the scope-sharded tables (integration/tool/etc). If |
| 60 | // any of these go missing the drizzle adapter's `getTable` lookup throws. | 60 | // any of these go missing the drizzle adapter's `getTable` lookup throws. |
| 61 | it("includes scope-sharded executor tables", () => { | 61 | it("includes scope-sharded executor tables", () => { |
| 62 | for (const key of Object.keys(executorSchema)) { | 62 | for (const key of Object.keys(executorSchema)) { |
| @@ -24,7 +24,7 @@ import * as executorSchema from "./executor-schema"; · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | // Exported so every drizzle() call in the cloud app shares one schema | 25 | // Exported so every drizzle() call in the cloud app shares one schema |
| 26 | // object. Historically `mcp-session.ts` built its own and forgot to spread | 26 | // object. Historically `mcp-session.ts` built its own and forgot to spread |
| 27 | // `executorSchema`, producing runtime "unknown model source" errors that | 27 | // `executorSchema`, producing runtime "unknown model integration" errors that |
| 28 | // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. | 28 | // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. |
| 29 | export const combinedSchema = { ...cloudSchema, ...executorSchema }; | 29 | export const combinedSchema = { ...cloudSchema, ...executorSchema }; |
| 30 | 30 | ||
| @@ -21,6 +21,8 @@ · 20 unmodified lines | |||
| 21 | // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through | 21 | // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through |
| 22 | // one PGLiteSocketServer and asserts zero protocol corruption. | 22 | // one PGLiteSocketServer and asserts zero protocol corruption. |
| 23 | 23 | ||
| 24 | import { setTimeout as sleep } from "node:timers/promises"; | ||
| 25 | import { connect, type Socket } from "node:net"; | ||
| 24 | import { describe, expect, it } from "@effect/vitest"; | 26 | import { describe, expect, it } from "@effect/vitest"; |
| 25 | import { PGlite } from "@electric-sql/pglite"; | 27 | import { PGlite } from "@electric-sql/pglite"; |
| 26 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; | 28 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| @@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines | |||
| 30 | const CLIENTS = 6; | 32 | const CLIENTS = 6; |
| 31 | const QUERIES_PER_CLIENT = 40; | 33 | const QUERIES_PER_CLIENT = 40; |
| 32 | 34 | ||
| 35 | const makeClient = (port: number, connectTimeout = 5) => | ||
| 36 | postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { | ||
| 37 | max: 1, | ||
| 38 | idle_timeout: 0, | ||
| 39 | connect_timeout: connectTimeout, | ||
| 40 | fetch_types: false, | ||
| 41 | prepare: true, | ||
| 42 | onnotice: () => undefined, | ||
| 43 | }); | ||
| 44 | |||
| 45 | // Hand-rolled wire client: connect and complete the trust-auth startup, so a | ||
| 46 | // test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js | ||
| 47 | // would never emit on its own. Resolves after ReadyForQuery so the next write | ||
| 48 | // is its own data event — and its own queue entry — on the server. | ||
| 49 | const openWireClient = async (port: number): Promise<Socket> => { | ||
| 50 | const socket: Socket = connect(port, "127.0.0.1"); | ||
| 51 | await new Promise<void>((res, rej) => { | ||
| 52 | socket.once("connect", res); | ||
| 53 | socket.once("error", rej); | ||
| 54 | }); | ||
| 55 | const startupBody = Buffer.concat([ | ||
| 56 | Buffer.from([0, 3, 0, 0]), | ||
| 57 | Buffer.from("user\0postgres\0database\0postgres\0\0"), | ||
| 58 | ]); | ||
| 59 | const startup = Buffer.concat([Buffer.alloc(4), startupBody]); | ||
| 60 | startup.writeInt32BE(startup.length, 0); | ||
| 61 | socket.write(startup); | ||
| 62 | await new Promise<void>((res) => { | ||
| 63 | socket.on("data", (chunk: Buffer) => { | ||
| 64 | if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | return socket; | ||
| 68 | }; | ||
| 69 | |||
| 70 | // A Parse frame for an unnamed statement: opens an extended-protocol pipeline | ||
| 71 | // that only a later Sync (or the server's recovery) closes. | ||
| 72 | const parseFrame = (query: string): Buffer => { | ||
| 73 | const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]); | ||
| 74 | const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]); | ||
| 75 | frame.writeInt32BE(4 + body.length, 1); | ||
| 76 | return frame; | ||
| 77 | }; | ||
| 78 | |||
| 33 | describe("dev-db PGlite socket under concurrent connections", () => { | 79 | describe("dev-db PGlite socket under concurrent connections", () => { |
| 34 | it( | 80 | it( |
| 35 | "serves interleaved multi-connection pipelines without protocol corruption", | 81 | "serves interleaved multi-connection pipelines without protocol corruption", |
| @@ -48,14 +94,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 12 unmodified lines | |||
| 48 | const errors: string[] = []; | 94 | const errors: string[] = []; |
| 49 | 95 | ||
| 50 | const worker = async (id: number) => { | 96 | const worker = async (id: number) => { |
| 51 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { | 97 | const sql = makeClient(PORT, 10); |
| 52 | max: 1, | ||
| 53 | idle_timeout: 0, | ||
| 54 | connect_timeout: 10, | ||
| 55 | fetch_types: false, | ||
| 56 | prepare: true, | ||
| 57 | onnotice: () => undefined, | ||
| 58 | }); | ||
| 59 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path | 98 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path |
| 60 | try { | 99 | try { |
| 61 | for (let q = 0; q < QUERIES_PER_CLIENT; q++) { | 100 | for (let q = 0; q < QUERIES_PER_CLIENT; q++) { |
| @@ -91,4 +130,229 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 29 unmodified lines | |||
| 91 | expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); | 130 | expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); |
| 92 | }, | 131 | }, |
| 93 | ); | 132 | ); |
| 133 | |||
| 134 | // Regression for the CI e2e "cloud signIn: callback set no session (500)" | ||
| 135 | // cascade: QueryQueueManager.processQueue used to `return` out of its drain | ||
| 136 | // loop when a query REJECTED (as opposed to returning a wire-level | ||
| 137 | // ErrorResponse), leaving `processing` latched true. From then on every | ||
| 138 | // enqueue — including brand-new connections' startup packets — sat in the | ||
| 139 | // queue forever: in-flight requests hung, postgres.js reconnects died with | ||
| 140 | // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The | ||
| 141 | // patch rejects the one entry, drops pipeline affinity, and keeps draining. | ||
| 142 | it( | ||
| 143 | "a rejected query fails one client, not the whole socket server", | ||
| 144 | { timeout: 30_000 }, | ||
| 145 | async () => { | ||
| 146 | const port = 45997; | ||
| 147 | const db = await PGlite.create(); | ||
| 148 | const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); | ||
| 149 | await server.start(); | ||
| 150 | |||
| 151 | const first = makeClient(port); | ||
| 152 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 153 | try { | ||
| 154 | expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); | ||
| 155 | |||
| 156 | // Force the NEXT protocol exchange to reject at the JS level, the shape | ||
| 157 | // PGlite produces when the shared session is broken mid-run. | ||
| 158 | const real = db.execProtocolRawStream.bind(db); | ||
| 159 | let arm = true; | ||
| 160 | (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => { | ||
| 161 | if (arm) { | ||
| 162 | arm = false; | ||
| 163 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw | ||
| 164 | throw new Error("synthetic PGlite failure"); | ||
| 165 | } | ||
| 166 | return real(...args); | ||
| 167 | }; | ||
| 168 | |||
| 169 | await expect(first.unsafe(`select 2 as two`)).rejects.toThrow(); | ||
| 170 | |||
| 171 | // The poisoned entry must take down only its own connection: a fresh | ||
| 172 | // client (new socket, full startup handshake) still gets served. | ||
| 173 | const second = makeClient(port); | ||
| 174 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 175 | try { | ||
| 176 | expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 }); | ||
| 177 | } finally { | ||
| 178 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 179 | await second.end({ timeout: 5 }).catch(() => {}); | ||
| 180 | } | ||
| 181 | } finally { | ||
| 182 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 183 | await first.end({ timeout: 5 }).catch(() => {}); | ||
| 184 | await server.stop(); | ||
| 185 | await db.close(); | ||
| 186 | } | ||
| 187 | }, | ||
| 188 | ); | ||
| 189 | |||
| 190 | // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's | ||
| 191 | // idleTimeout backstop used to kill ANY connection with no traffic for the | ||
| 192 | // window, which is the resting state of every healthy postgres.js pool | ||
| 193 | // connection (idle_timeout: 0) held by a long-lived scope. The backstop now | ||
| 194 | // only fires on a connection that is actually blocking the shared session — | ||
| 195 | // an open pipeline or an open transaction. | ||
| 196 | it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => { | ||
| 197 | const port = 45996; | ||
| 198 | const db = await PGlite.create(); | ||
| 199 | const server = new PGLiteSocketServer({ | ||
| 200 | db, | ||
| 201 | port, | ||
| 202 | host: "127.0.0.1", | ||
| 203 | maxConnections: 100, | ||
| 204 | idleTimeout: 250, | ||
| 205 | }); | ||
| 206 | await server.start(); | ||
| 207 | |||
| 208 | const sql = makeClient(port); | ||
| 209 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 210 | try { | ||
| 211 | expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); | ||
| 212 | await sleep(900); | ||
| 213 | expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 }); | ||
| 214 | } finally { | ||
| 215 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 216 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 217 | await server.stop(); | ||
| 218 | await db.close(); | ||
| 219 | } | ||
| 220 | }); | ||
| 221 | |||
| 222 | // The backstop's actual job still works: a client that opens a pipeline | ||
| 223 | // (Parse sent, never Sync) and goes silent holds queue affinity, which | ||
| 224 | // starves every other connection. The idle timer must reap exactly that | ||
| 225 | // client and hand the queue back. | ||
| 226 | it( | ||
| 227 | "a client stalled mid-pipeline is reaped and the queue recovers", | ||
| 228 | { timeout: 30_000 }, | ||
| 229 | async () => { | ||
| 230 | const port = 45995; | ||
| 231 | const db = await PGlite.create(); | ||
| 232 | const server = new PGLiteSocketServer({ | ||
| 233 | db, | ||
| 234 | port, | ||
| 235 | host: "127.0.0.1", | ||
| 236 | maxConnections: 100, | ||
| 237 | idleTimeout: 250, | ||
| 238 | }); | ||
| 239 | await server.start(); | ||
| 240 | |||
| 241 | // Hand-rolled wire client: complete the trust-auth startup, then send a | ||
| 242 | // lone Parse. Its last frame type ('P') marks the pipeline open, so the | ||
| 243 | // handler takes affinity and every other connection queues behind it. | ||
| 244 | const staller = await openWireClient(port); | ||
| 245 | staller.write(parseFrame("select 1")); | ||
| 246 | |||
| 247 | const bystander = makeClient(port, 10); | ||
| 248 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 249 | try { | ||
| 250 | // Connects and queries only once the staller is reaped (~250ms). | ||
| 251 | expect((await bystander.unsafe(`select 4 as four`))[0]).toEqual({ four: 4 }); | ||
| 252 | } finally { | ||
| 253 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 254 | await bystander.end({ timeout: 5 }).catch(() => {}); | ||
| 255 | staller.destroy(); | ||
| 256 | await server.stop(); | ||
| 257 | await db.close(); | ||
| 258 | } | ||
| 259 | }, | ||
| 260 | ); | ||
| 261 | |||
| 262 | // Regression for the reap SLOT LEAK: detach(true) removes the socket's | ||
| 263 | // listeners before destroying it, so a server-initiated teardown (the idle | ||
| 264 | // backstop) never fired the server's 'close' bookkeeping — the reaped | ||
| 265 | // handler stayed in the server's handlers set forever, burning one | ||
| 266 | // maxConnections slot per reap. Enough reaps over a long run and the server | ||
| 267 | // answers every NEW connection with "Too many connections" while the | ||
| 268 | // process, the port, and PGlite are all healthy — postgres.js surfaces that | ||
| 269 | // as the same CONNECT_TIMEOUT cascade as the queue wedges. The server now | ||
| 270 | // drops the handler when it dispatches its terminal error. | ||
| 271 | it("reaped handlers release their connection slots", { timeout: 30_000 }, async () => { | ||
| 272 | const port = 45993; | ||
| 273 | const db = await PGlite.create(); | ||
| 274 | const server = new PGLiteSocketServer({ | ||
| 275 | db, | ||
| 276 | port, | ||
| 277 | host: "127.0.0.1", | ||
| 278 | maxConnections: 2, | ||
| 279 | idleTimeout: 250, | ||
| 280 | }); | ||
| 281 | await server.start(); | ||
| 282 | |||
| 283 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 284 | try { | ||
| 285 | // Burn through more reaps than there are slots: each staller opens a | ||
| 286 | // pipeline and goes silent, so the idle backstop reaps it (the server | ||
| 287 | // destroys the socket — its 'close' marks that reap complete). | ||
| 288 | for (let i = 0; i < 3; i++) { | ||
| 289 | const staller = await openWireClient(port); | ||
| 290 | staller.write(parseFrame(`select ${i + 1}`)); | ||
| 291 | await new Promise<void>((res) => staller.once("close", res)); | ||
| 292 | } | ||
| 293 | |||
| 294 | expect( | ||
| 295 | server.getStats().activeConnections, | ||
| 296 | "reaped handlers stay counted against maxConnections", | ||
| 297 | ).toBe(0); | ||
| 298 | |||
| 299 | const sql = makeClient(port); | ||
| 300 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 301 | try { | ||
| 302 | expect((await sql.unsafe(`select 6 as six`))[0]).toEqual({ six: 6 }); | ||
| 303 | } finally { | ||
| 304 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 305 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 306 | } | ||
| 307 | } finally { | ||
| 308 | await server.stop(); | ||
| 309 | await db.close(); | ||
| 310 | } | ||
| 311 | }); | ||
| 312 | |||
| 313 | // Regression for the second wedge mode behind the same CI cascade: a client | ||
| 314 | // whose socket dies WHILE its pipeline-opening entry is executing. detach() | ||
| 315 | // clears pipeline affinity before the entry finishes, so the queue then | ||
| 316 | // assigned affinity to the already-dead handler — and nothing ever cleared | ||
| 317 | // it: the dead handler has no timers left, and every other connection | ||
| 318 | // (including fresh startups) queued behind the ghost forever. The queue now | ||
| 319 | // tracks detached handlers and repairs affinity they can no longer release. | ||
| 320 | it( | ||
| 321 | "a client that dies mid-execution does not leave the queue pinned to its ghost", | ||
| 322 | { timeout: 30_000 }, | ||
| 323 | async () => { | ||
| 324 | const port = 45994; | ||
| 325 | const db = await PGlite.create(); | ||
| 326 | |||
| 327 | // Hold the marker query in flight long enough that the disconnect below | ||
| 328 | // reliably lands while the entry is EXECUTING (after detach's cleanup, | ||
| 329 | // before the queue takes affinity for it). | ||
| 330 | const real = db.execProtocolRawStream.bind(db); | ||
| 331 | (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => { | ||
| 332 | if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300); | ||
| 333 | return real(...args); | ||
| 334 | }; | ||
| 335 | |||
| 336 | const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); | ||
| 337 | await server.start(); | ||
| 338 | |||
| 339 | const ghost = await openWireClient(port); | ||
| 340 | ghost.write(parseFrame("select 'ghost_marker'")); | ||
| 341 | // Give the data event time to reach the queue and start executing, then | ||
| 342 | // die without a trace mid-flight. | ||
| 343 | await sleep(100); | ||
| 344 | ghost.destroy(); | ||
| 345 | |||
| 346 | const bystander = makeClient(port); | ||
| 347 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 348 | try { | ||
| 349 | expect((await bystander.unsafe(`select 5 as five`))[0]).toEqual({ five: 5 }); | ||
| 350 | } finally { | ||
| 351 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 352 | await bystander.end({ timeout: 5 }).catch(() => {}); | ||
| 353 | await server.stop(); | ||
| 354 | await db.close(); | ||
| 355 | } | ||
| 356 | }, | ||
| 357 | ); | ||
| 94 | }); | 358 | }); |
| @@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines | |||
| 33 | (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], | 33 | (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], |
| 34 | ); | 34 | ); |
| 35 | 35 | ||
| 36 | export const subject = pgTable( | ||
| 37 | "subject", | ||
| 38 | { | ||
| 39 | external_id: varchar("external_id", { length: 255 }).notNull(), | ||
| 40 | created_at: timestamp("created_at").notNull(), | ||
| 41 | last_seen_at: bigint("last_seen_at", { mode: "bigint" }), | ||
| 42 | status: text("status"), | ||
| 43 | row_id: varchar("row_id", { length: 255 }) | ||
| 44 | .primaryKey() | ||
| 45 | .notNull() | ||
| 46 | .$defaultFn(() => createId()), | ||
| 47 | tenant: varchar("tenant", { length: 255 }).notNull(), | ||
| 48 | }, | ||
| 49 | (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], | ||
| 50 | ); | ||
| 51 | |||
| 36 | export const connection = pgTable( | 52 | export const connection = pgTable( |
| 37 | "connection", | 53 | "connection", |
| 38 | { | 54 | { |
| @@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines | |||
| 86 | origin_kind: text("origin_kind"), | 102 | origin_kind: text("origin_kind"), |
| 87 | origin_integration: text("origin_integration"), | 103 | origin_integration: text("origin_integration"), |
| 88 | origin_issuer: text("origin_issuer"), | 104 | origin_issuer: text("origin_issuer"), |
| 105 | origin_redirect_uri: text("origin_redirect_uri"), | ||
| 89 | created_at: timestamp("created_at").notNull(), | 106 | created_at: timestamp("created_at").notNull(), |
| 90 | row_id: varchar("row_id", { length: 255 }) | 107 | row_id: varchar("row_id", { length: 255 }) |
| 91 | .primaryKey() | 108 | .primaryKey() |
| @@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines | |||
| 164 | integration: varchar("integration", { length: 255 }).notNull(), | 181 | integration: varchar("integration", { length: 255 }).notNull(), |
| 165 | connection: varchar("connection", { length: 255 }).notNull(), | 182 | connection: varchar("connection", { length: 255 }).notNull(), |
| 166 | plugin_id: text("plugin_id").notNull(), | 183 | plugin_id: text("plugin_id").notNull(), |
| 167 | name: varchar("name", { length: 255 }).notNull(), | 184 | name: text("name").notNull(), |
| 168 | schema: json("schema").notNull(), | 185 | schema: json("schema").notNull(), |
| 169 | created_at: timestamp("created_at").notNull(), | 186 | created_at: timestamp("created_at").notNull(), |
| 170 | row_id: varchar("row_id", { length: 255 }) | 187 | row_id: varchar("row_id", { length: 255 }) |
| @@ -209,6 +226,28 @@ export const tool_policy = pgTable( · 38 unmodified lines | |||
| 209 | ], | 226 | ], |
| 210 | ); | 227 | ); |
| 211 | 228 | ||
| 229 | export const artifact = pgTable( | ||
| 230 | "artifact", | ||
| 231 | { | ||
| 232 | id: varchar("id", { length: 255 }).notNull(), | ||
| 233 | title: text("title").notNull(), | ||
| 234 | description: text("description"), | ||
| 235 | code: text("code").notNull(), | ||
| 236 | bindings: json("bindings"), | ||
| 237 | preview: text("preview"), | ||
| 238 | created_at: timestamp("created_at").notNull(), | ||
| 239 | updated_at: timestamp("updated_at").notNull(), | ||
| 240 | row_id: varchar("row_id", { length: 255 }) | ||
| 241 | .primaryKey() | ||
| 242 | .notNull() | ||
| 243 | .$defaultFn(() => createId()), | ||
| 244 | tenant: varchar("tenant", { length: 255 }).notNull(), | ||
| 245 | owner: varchar("owner", { length: 255 }).notNull(), | ||
| 246 | subject: varchar("subject", { length: 255 }).notNull(), | ||
| 247 | }, | ||
| 248 | (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], | ||
| 249 | ); | ||
| 250 | |||
| 212 | export const plugin_storage = pgTable( | 251 | export const plugin_storage = pgTable( |
| 213 | "plugin_storage", | 252 | "plugin_storage", |
| 214 | { | 253 | { |
| @@ -11,16 +11,24 @@ · 10 unmodified lines | |||
| 11 | // - a second org's data is completely untouched | 11 | // - a second org's data is completely untouched |
| 12 | // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must | 12 | // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must |
| 13 | // not widen the match to a look-alike namespace) | 13 | // not widen the match to a look-alike namespace) |
| 14 | // | ||
| 15 | // Plus a SCHEMA-DRIFT guard (see "purge coverage" below): the purge list is | ||
| 16 | // hand-written, so a table added to the schema without being added to it would | ||
| 17 | // leave that org's rows behind forever, silently. The guard derives the set of | ||
| 18 | // org-owned tables from the schema itself rather than restating it. | ||
| 14 | 19 | ||
| 15 | import { describe, it, expect } from "@effect/vitest"; | 20 | import { describe, it, expect } from "@effect/vitest"; |
| 16 | import { Effect } from "effect"; | 21 | import { Effect } from "effect"; |
| 17 | import { eq, inArray } from "drizzle-orm"; | 22 | import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm"; |
| 18 | 23 | ||
| 19 | import { DbService } from "./db"; | 24 | import { DbService } from "./db"; |
| 20 | import type { DrizzleDb } from "./db"; | 25 | import type { DrizzleDb } from "./db"; |
| 21 | import { makeUserStore } from "../auth/user-store"; | 26 | import { makeUserStore } from "../auth/user-store"; |
| 27 | import * as cloudSchema from "./schema"; | ||
| 28 | import * as executorSchema from "./executor-schema"; | ||
| 22 | import { memberships, accounts } from "./schema"; | 29 | import { memberships, accounts } from "./schema"; |
| 23 | import { | 30 | import { |
| 31 | artifact, | ||
| 24 | blob, | 32 | blob, |
| 25 | connection, | 33 | connection, |
| 26 | definition, | 34 | definition, |
| @@ -28,6 +36,7 @@ import { · 1 unmodified line | |||
| 28 | oauth_client, | 36 | oauth_client, |
| 29 | oauth_session, | 37 | oauth_session, |
| 30 | plugin_storage, | 38 | plugin_storage, |
| 39 | subject, | ||
| 31 | tool, | 40 | tool, |
| 32 | tool_policy, | 41 | tool_policy, |
| 33 | } from "./executor-schema"; | 42 | } from "./executor-schema"; |
| @@ -131,6 +140,24 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines | |||
| 131 | subject: "s", | 140 | subject: "s", |
| 132 | }); | 141 | }); |
| 133 | 142 | ||
| 143 | await db.insert(subject).values({ | ||
| 144 | external_id: `acct-${tag}`, | ||
| 145 | created_at: now, | ||
| 146 | last_seen_at: BigInt(now.getTime()), | ||
| 147 | tenant, | ||
| 148 | }); | ||
| 149 | |||
| 150 | await db.insert(artifact).values({ | ||
| 151 | id: `art-${tag}`, | ||
| 152 | title: "Dashboard", | ||
| 153 | code: "export default function App() { return null; }", | ||
| 154 | created_at: now, | ||
| 155 | updated_at: now, | ||
| 156 | tenant, | ||
| 157 | owner: "o", | ||
| 158 | subject: "s", | ||
| 159 | }); | ||
| 160 | |||
| 134 | const orgNs = `o:${tenant}/plugin`; | 161 | const orgNs = `o:${tenant}/plugin`; |
| 135 | const userNs = `u:${tenant}:subject/plugin`; | 162 | const userNs = `u:${tenant}:subject/plugin`; |
| 136 | await db.insert(blob).values({ | 163 | await db.insert(blob).values({ |
| @@ -156,8 +183,26 @@ const TENANT_TABLES = [ · 19 unmodified lines | |||
| 156 | definition, | 183 | definition, |
| 157 | tool_policy, | 184 | tool_policy, |
| 158 | plugin_storage, | 185 | plugin_storage, |
| 186 | subject, | ||
| 187 | artifact, | ||
| 159 | ] as const; | 188 | ] as const; |
| 160 | 189 | ||
| 190 | // Tables that are NOT purged by org id, each with the reason it is exempt. Any | ||
| 191 | // schema table not carrying a `tenant` column must be listed here — that is | ||
| 192 | // what makes the drift guard below total rather than a spot check. | ||
| 193 | const NOT_ORG_OWNED: Record<string, string> = { | ||
| 194 | // Purged, but by NAMESPACE PREFIX (`o:<org>/…` / `u:<org>:…`) rather than a | ||
| 195 | // tenant column, so it cannot be checked by the column rule. | ||
| 196 | blob: "org-scoped by namespace prefix, purged via the LIKE match", | ||
| 197 | // Instance-wide, not owned by any org. | ||
| 198 | private_executor_cloud_settings: "singleton instance settings, not org-scoped", | ||
| 199 | // Identity mirror. `organizations` is deleted directly and `memberships` | ||
| 200 | // cascades from its FK; `accounts` deliberately outlives the org. | ||
| 201 | organizations: "the identity row itself, deleted directly", | ||
| 202 | memberships: "cascades from the organizations FK", | ||
| 203 | accounts: "shared across orgs — deliberately survives", | ||
| 204 | }; | ||
| 205 | |||
| 161 | const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => { | 206 | const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => { |
| 162 | let total = 0; | 207 | let total = 0; |
| 163 | for (const table of TENANT_TABLES) { | 208 | for (const table of TENANT_TABLES) { |
| @@ -249,3 +294,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines | |||
| 249 | ); | 294 | ); |
| 250 | }, 20_000); | 295 | }, 20_000); |
| 251 | }); | 296 | }); |
| 297 | |||
| 298 | // --------------------------------------------------------------------------- | ||
| 299 | // Purge coverage vs the schema | ||
| 300 | // --------------------------------------------------------------------------- | ||
| 301 | // | ||
| 302 | // The purge list in `org-deletion.ts` is hand-written, one `tx.delete(...)` per | ||
| 303 | // table. Adding a tenant table to the schema and forgetting that line leaves | ||
| 304 | // every deleted org's rows in it forever — no error, no failing test, and the | ||
| 305 | // data is exactly what a deletion was supposed to remove. | ||
| 306 | // | ||
| 307 | // So the expected set is DERIVED: a `tenant` column is what makes a table | ||
| 308 | // org-owned, and every table without one must be named in `NOT_ORG_OWNED` with | ||
| 309 | // its reason. A new table lands in one of the two buckets automatically, and | ||
| 310 | // both buckets fail loudly until it is handled. | ||
| 311 | describe("purge coverage", () => { | ||
| 312 | // Every drizzle table the cloud DB is built from (the same two modules | ||
| 313 | // `combinedSchema` spreads), by table name. | ||
| 314 | const allTables = Object.values({ ...cloudSchema, ...executorSchema }); | ||
| 315 | |||
| 316 | const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table)); | ||
| 317 | const orgOwnedNames = orgOwnedTables.map(getTableName).sort(); | ||
| 318 | const seededNames = TENANT_TABLES.map(getTableName).sort(); | ||
| 319 | |||
| 320 | it("classifies every schema table as org-owned or explicitly exempt", () => { | ||
| 321 | const unclassified = allTables | ||
| 322 | .map(getTableName) | ||
| 323 | .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED)); | ||
| 324 | expect( | ||
| 325 | unclassified, | ||
| 326 | "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason", | ||
| 327 | ).toEqual([]); | ||
| 328 | }); | ||
| 329 | |||
| 330 | it("purges every org-owned table in the schema", () => { | ||
| 331 | // `TENANT_TABLES` is what this suite seeds and then asserts is empty after | ||
| 332 | // the purge, so keeping it equal to the schema-derived set is what makes | ||
| 333 | // the cascade test above cover the WHOLE schema rather than a snapshot of | ||
| 334 | // it. A tenant table added tomorrow fails here first — the fix is to seed | ||
| 335 | // it, which then fails the cascade test until `purgeOrganizationData` | ||
| 336 | // deletes it too. | ||
| 337 | expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual( | ||
| 338 | orgOwnedNames, | ||
| 339 | ); | ||
| 340 | }); | ||
| 341 | |||
| 342 | it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => { | ||
| 343 | // Guards the other half: a table listed in TENANT_TABLES but never seeded | ||
| 344 | // would count zero rows before AND after the purge, passing while proving | ||
| 345 | // nothing. Assert the seed actually populates each one. | ||
| 346 | const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`; | ||
| 347 | const empty = await program( | ||
| 348 | Effect.gen(function* () { | ||
| 349 | const { db } = yield* DbService; | ||
| 350 | return yield* Effect.promise(async () => { | ||
| 351 | await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`); | ||
| 352 | const missing: Array<string> = []; | ||
| 353 | for (const table of TENANT_TABLES) { | ||
| 354 | const rows = await db.select().from(table).where(eq(table.tenant, tenant)); | ||
| 355 | if (rows.length === 0) missing.push(getTableName(table)); | ||
| 356 | } | ||
| 357 | return missing; | ||
| 358 | }); | ||
| 359 | }), | ||
| 360 | ); | ||
| 361 | expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]); | ||
| 362 | }, 20_000); | ||
| 363 | }); | ||
| @@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; · 15 unmodified lines | |||
| 16 | import type { DrizzleDb } from "./db"; | 16 | import type { DrizzleDb } from "./db"; |
| 17 | import { organizations } from "./schema"; | 17 | import { organizations } from "./schema"; |
| 18 | import { | 18 | import { |
| 19 | artifact, | ||
| 19 | blob, | 20 | blob, |
| 20 | connection, | 21 | connection, |
| 21 | definition, | 22 | definition, |
| @@ -23,6 +24,7 @@ import { · 1 unmodified line | |||
| 23 | oauth_client, | 24 | oauth_client, |
| 24 | oauth_session, | 25 | oauth_session, |
| 25 | plugin_storage, | 26 | plugin_storage, |
| 27 | subject, | ||
| 26 | tool, | 28 | tool, |
| 27 | tool_policy, | 29 | tool_policy, |
| 28 | } from "./executor-schema"; | 30 | } from "./executor-schema"; |
| @@ -48,6 +50,8 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines | |||
| 48 | await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); | 50 | await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); |
| 49 | await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); | 51 | await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); |
| 50 | await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); | 52 | await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); |
| 53 | await tx.delete(subject).where(eq(subject.tenant, organizationId)); | ||
| 54 | await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); | ||
| 51 | 55 | ||
| 52 | // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by | 56 | // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by |
| 53 | // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>` | 57 | // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>` |
| @@ -6,43 +6,23 @@ · 5 unmodified lines | |||
| 6 | // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap | 6 | // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap |
| 7 | // to the upstream origin (unlike the PostHog proxy, which strips its prefix). | 7 | // to the upstream origin (unlike the PostHog proxy, which strips its prefix). |
| 8 | // | 8 | // |
| 9 | // Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs | 9 | // The matching, upstream construction, and client span live in `./passthrough`, |
| 10 | // the prod-only `env.MARKETING` service binding), this is a plain external | 10 | // which server.ts dispatches BEFORE Start loads: forwarding a docs page must |
| 11 | // `fetch`, so it runs on every host — `/docs` previews against live Mintlify in | 11 | // not pay for the whole server graph. This middleware stays registered so hosts |
| 12 | // local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), | 12 | // that reach Start by another entry (local dev) keep identical behavior; in the |
| 13 | // so this never shadows an Effect-served route. | 13 | // deployed Worker it is unreachable. `/docs` is distinct from the app-owned |
| 14 | // `/api/docs` (Swagger), so this never shadows an Effect-served route. | ||
| 14 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 15 | 16 | ||
| 16 | import { createMiddleware } from "@tanstack/react-start"; | 17 | import { createMiddleware } from "@tanstack/react-start"; |
| 17 | 18 | ||
| 18 | const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; | 19 | import { docsProxyResponse, isDocsPath } from "./passthrough"; |
| 19 | 20 | ||
| 20 | export const isDocsPath = (pathname: string) => | 21 | export { buildDocsUpstream, isDocsPath } from "./passthrough"; |
| 21 | pathname === "/docs" || pathname.startsWith("/docs/"); | ||
| 22 | |||
| 23 | // Build the upstream request for an already-classified `/docs` path. Caller | ||
| 24 | // guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the | ||
| 25 | // forwarding headers, preserving method, body, path, and query. | ||
| 26 | export const buildDocsUpstream = (request: Request): Request => { | ||
| 27 | const url = new URL(request.url); | ||
| 28 | const forwardedHost = url.host; | ||
| 29 | |||
| 30 | url.hostname = DOCS_UPSTREAM_HOST; | ||
| 31 | url.protocol = "https:"; | ||
| 32 | url.port = ""; | ||
| 33 | |||
| 34 | const upstream = new Request(url, request); | ||
| 35 | // Mintlify keys canonical links off the public host; tell it the real one. | ||
| 36 | upstream.headers.set("X-Forwarded-Host", forwardedHost); | ||
| 37 | upstream.headers.set("X-Forwarded-Proto", "https"); | ||
| 38 | // Never leak the executor.sh session cookie to the docs origin. | ||
| 39 | upstream.headers.delete("cookie"); | ||
| 40 | return upstream; | ||
| 41 | }; | ||
| 42 | 22 | ||
| 43 | export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( | 23 | export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( |
| 44 | ({ pathname, request, next }) => { | 24 | ({ pathname, request, next }) => { |
| 45 | if (!isDocsPath(pathname)) return next(); | 25 | if (!isDocsPath(pathname)) return next(); |
| 46 | return fetch(buildDocsUpstream(request)); | 26 | return docsProxyResponse(request, pathname); |
| 47 | }, | 27 | }, |
| 48 | ); | 28 | ); |
| @@ -1,11 +1,10 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Edge concerns — the analytics/marketing/docs request middlewares that run at | 2 | // Edge concerns — request middleware that runs before the app's own mcp + api |
| 3 | // the worker edge BEFORE the app's own mcp + api dispatch. None of these touch | 3 | // dispatch. These proxy or tunnel to external services without touching the |
| 4 | // the Effect app layer; they proxy or tunnel to external services (the | 4 | // Effect app layer. Marketing is dispatched even earlier, from server.ts, so a |
| 5 | // marketing worker, Sentry, PostHog, Mintlify docs). | 5 | // public page never loads the TanStack Start graph. |
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | export { marketingMiddleware } from "./marketing"; | ||
| 9 | export { sentryTunnelMiddleware } from "./sentry-tunnel"; | 8 | export { sentryTunnelMiddleware } from "./sentry-tunnel"; |
| 10 | export { posthogProxyMiddleware } from "./posthog"; | 9 | export { posthogProxyMiddleware } from "./posthog"; |
| 11 | export { docsProxyMiddleware } from "./docs"; | 10 | export { docsProxyMiddleware } from "./docs"; |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { isMarketingPath } from "./marketing"; | 3 | import { isMarketingPath, marketingProxyRequest } from "./marketing"; |
| 4 | 4 | ||
| 5 | // On executor.sh the marketing middleware proxies an allow-list of paths to the | 5 | // On executor.sh the marketing middleware proxies an allow-list of paths to the |
| 6 | // `executor-marketing` worker; everything else falls through to the auth-gated | 6 | // `executor-marketing` worker; everything else falls through to the auth-gated |
| @@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines | |||
| 12 | "/home", | 12 | "/home", |
| 13 | "/privacy", | 13 | "/privacy", |
| 14 | "/terms", | 14 | "/terms", |
| 15 | "/about-executor", | ||
| 16 | "/google-oauth", | ||
| 17 | "/google-workspace", | ||
| 15 | "/blog", | 18 | "/blog", |
| 16 | "/blog/", | 19 | "/blog/", |
| 17 | "/blog/some-post", | 20 | "/blog/some-post", |
| @@ -37,3 +40,54 @@ describe("isMarketingPath", () => { · 19 unmodified lines | |||
| 37 | }); | 40 | }); |
| 38 | } | 41 | } |
| 39 | }); | 42 | }); |
| 43 | |||
| 44 | describe("marketingProxyRequest", () => { | ||
| 45 | it("routes a signed-out homepage request", () => { | ||
| 46 | const request = new Request("https://executor.sh/?source=test"); | ||
| 47 | |||
| 48 | const proxied = marketingProxyRequest(request); | ||
| 49 | |||
| 50 | expect(proxied?.url).toBe("https://executor.sh/?source=test"); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("leaves the signed-in homepage with the cloud application", () => { | ||
| 54 | const request = new Request("https://executor.sh/", { | ||
| 55 | headers: { cookie: "other=value; wos-session=sealed" }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(marketingProxyRequest(request)).toBeNull(); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("routes public content even when a session cookie is present", () => { | ||
| 62 | const request = new Request("https://executor.sh/blog/post", { | ||
| 63 | headers: { cookie: "wos-session=sealed" }, | ||
| 64 | }); | ||
| 65 | |||
| 66 | expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("rewrites the public home alias to the marketing root", () => { | ||
| 70 | const request = new Request("https://executor.sh/home?source=test"); | ||
| 71 | |||
| 72 | expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("preserves the request method, headers, and body", async () => { | ||
| 76 | const request = new Request("https://executor.sh/_astro/_ph/capture", { | ||
| 77 | method: "POST", | ||
| 78 | headers: { "content-type": "application/json", "x-request-id": "request-1" }, | ||
| 79 | body: JSON.stringify({ event: "test" }), | ||
| 80 | }); | ||
| 81 | |||
| 82 | const proxied = marketingProxyRequest(request); | ||
| 83 | |||
| 84 | expect(proxied?.method).toBe("POST"); | ||
| 85 | expect(proxied?.headers.get("x-request-id")).toBe("request-1"); | ||
| 86 | await expect(proxied?.json()).resolves.toEqual({ event: "test" }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("does not proxy non-production hosts or app-owned paths", () => { | ||
| 90 | expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); | ||
| 91 | expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); | ||
| 92 | }); | ||
| 93 | }); | ||
| @@ -3,14 +3,10 @@ · 2 unmodified lines | |||
| 3 | // | 3 | // |
| 4 | // On the production domain (`executor.sh`), marketing paths and the | 4 | // On the production domain (`executor.sh`), marketing paths and the |
| 5 | // unauthenticated landing page are served by the separate `executor-marketing` | 5 | // unauthenticated landing page are served by the separate `executor-marketing` |
| 6 | // worker (bound as `env.MARKETING`). In local dev that worker isn't running, so | 6 | // worker. This module deliberately has no TanStack Start or cloud application |
| 7 | // unauthenticated visits fall through to the cloud app's routes (the sign-in | 7 | // imports: the Worker entry calls it before loading the Start server graph. |
| 8 | // page). | ||
| 9 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 10 | 9 | ||
| 11 | import { env } from "cloudflare:workers"; | ||
| 12 | import { createMiddleware } from "@tanstack/react-start"; | ||
| 13 | |||
| 14 | import { parseCookie } from "../auth/cookies"; | 10 | import { parseCookie } from "../auth/cookies"; |
| 15 | 11 | ||
| 16 | const MARKETING_PATHS = [ | 12 | const MARKETING_PATHS = [ |
| @@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line | |||
| 18 | "/setup", | 14 | "/setup", |
| 19 | "/privacy", | 15 | "/privacy", |
| 20 | "/terms", | 16 | "/terms", |
| 17 | "/about-executor", | ||
| 18 | "/google-oauth", | ||
| 19 | "/google-workspace", | ||
| 21 | "/blog", | 20 | "/blog", |
| 22 | "/changelog", | 21 | "/changelog", |
| 23 | "/changelog.json", | 22 | "/changelog.json", |
| @@ -29,33 +28,25 @@ const MARKETING_PATHS = [ · 5 unmodified lines | |||
| 29 | "/pattern-graph-paper.svg", | 28 | "/pattern-graph-paper.svg", |
| 30 | ]; | 29 | ]; |
| 31 | 30 | ||
| 32 | export const isMarketingPath = (pathname: string) => | 31 | const SESSION_COOKIE = "wos-session"; |
| 33 | MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); | ||
| 34 | |||
| 35 | const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; | ||
| 36 | |||
| 37 | export const marketingMiddleware = createMiddleware({ type: "request" }).server( | ||
| 38 | async ({ pathname, request, next }) => { | ||
| 39 | // Only proxy to the marketing worker on the production domain. In local | ||
| 40 | // dev we don't run `executor-marketing`, so unauthenticated visits fall | ||
| 41 | // through to the cloud app's routes (which show the sign-in page). | ||
| 42 | const host = new URL(request.url).hostname; | ||
| 43 | if (host !== "executor.sh") return next(); | ||
| 44 | 32 | ||
| 45 | const shouldProxyToMarketing = | 33 | /** Whether an exact pathname belongs to the public marketing worker. */ |
| 46 | isMarketingPath(pathname) || | 34 | export const isMarketingPath = (pathname: string): boolean => |
| 47 | (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); | 35 | MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); |
| 48 | |||
| 49 | if (!shouldProxyToMarketing) return next(); | ||
| 50 | |||
| 51 | const marketing = getMarketingWorker(); | ||
| 52 | if (!marketing) return next(); | ||
| 53 | 36 | ||
| 54 | const url = new URL(request.url); | 37 | /** |
| 55 | // Rewrite /home to / so marketing worker serves its homepage | 38 | * Project a production request onto the marketing service-binding request. |
| 56 | if (pathname === "/home") { | 39 | * Returns `null` when the cloud application owns the request instead. |
| 57 | url.pathname = "/"; | 40 | */ |
| 58 | } | 41 | export const marketingProxyRequest = (request: Request): Request | null => { |
| 59 | return marketing.fetch(new Request(url, request)); | 42 | const url = new URL(request.url); |
| 60 | }, | 43 | if (url.hostname !== "executor.sh") return null; |
| 61 | ); | 44 | |
| 45 | const shouldProxy = | ||
| 46 | isMarketingPath(url.pathname) || | ||
| 47 | (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); | ||
| 48 | if (!shouldProxy) return null; | ||
| 49 | |||
| 50 | if (url.pathname === "/home") url.pathname = "/"; | ||
| 51 | return new Request(url, request); | ||
| 52 | }; | ||
| @@ -0,0 +1,85 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildDocsUpstream, | ||
| 6 | buildPosthogUpstream, | ||
| 7 | isDocsPath, | ||
| 8 | isPosthogPath, | ||
| 9 | passthroughResponse, | ||
| 10 | POSTHOG_PROXY_PATH, | ||
| 11 | } from "./passthrough"; | ||
| 12 | |||
| 13 | describe("passthrough matching", () => { | ||
| 14 | it("claims /docs and everything under it, but not /api/docs", () => { | ||
| 15 | expect(isDocsPath("/docs")).toBe(true); | ||
| 16 | expect(isDocsPath("/docs/concepts/policies")).toBe(true); | ||
| 17 | // The app-owned Swagger route must keep reaching the Effect app. | ||
| 18 | expect(isDocsPath("/api/docs")).toBe(false); | ||
| 19 | expect(isDocsPath("/docsearch")).toBe(false); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("claims the PostHog proxy path and its subtree only", () => { | ||
| 23 | expect(isPosthogPath(POSTHOG_PROXY_PATH)).toBe(true); | ||
| 24 | expect(isPosthogPath(`${POSTHOG_PROXY_PATH}/i/v0/e/`)).toBe(true); | ||
| 25 | expect(isPosthogPath(`${POSTHOG_PROXY_PATH}extra`)).toBe(false); | ||
| 26 | expect(isPosthogPath("/api/connections")).toBe(false); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("returns null for app paths so they fall through to normal dispatch", () => { | ||
| 30 | expect(passthroughResponse(new Request("https://executor.sh/"), "/")).toBeNull(); | ||
| 31 | expect( | ||
| 32 | passthroughResponse(new Request("https://executor.sh/api/connections"), "/api/connections"), | ||
| 33 | ).toBeNull(); | ||
| 34 | expect(passthroughResponse(new Request("https://executor.sh/mcp"), "/mcp")).toBeNull(); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | describe("upstream construction", () => { | ||
| 39 | it("forwards the docs path unchanged and strips the session cookie", () => { | ||
| 40 | const upstream = buildDocsUpstream( | ||
| 41 | new Request("https://executor.sh/docs/concepts/policies?x=1", { | ||
| 42 | headers: { cookie: "wos-session=secret" }, | ||
| 43 | }), | ||
| 44 | ); | ||
| 45 | const url = new URL(upstream.url); | ||
| 46 | expect(url.hostname).toBe("executor.mintlify.dev"); | ||
| 47 | expect(url.pathname).toBe("/docs/concepts/policies"); | ||
| 48 | expect(url.search).toBe("?x=1"); | ||
| 49 | expect(upstream.headers.get("X-Forwarded-Host")).toBe("executor.sh"); | ||
| 50 | expect(upstream.headers.get("cookie")).toBeNull(); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("strips the proxy prefix for PostHog and splits ingest from assets", () => { | ||
| 54 | const ingest = buildPosthogUpstream( | ||
| 55 | new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/i/v0/e/`), | ||
| 56 | `${POSTHOG_PROXY_PATH}/i/v0/e/`, | ||
| 57 | ); | ||
| 58 | expect(new URL(ingest.url).hostname).toBe("us.i.posthog.com"); | ||
| 59 | expect(new URL(ingest.url).pathname).toBe("/i/v0/e/"); | ||
| 60 | |||
| 61 | const assets = buildPosthogUpstream( | ||
| 62 | new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/static/array.js`), | ||
| 63 | `${POSTHOG_PROXY_PATH}/static/array.js`, | ||
| 64 | ); | ||
| 65 | expect(new URL(assets.url).hostname).toBe("us-assets.i.posthog.com"); | ||
| 66 | expect(new URL(assets.url).pathname).toBe("/static/array.js"); | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 70 | describe("Start-graph independence", () => { | ||
| 71 | // The entire point of this module is that server.ts can answer a proxy | ||
| 72 | // request WITHOUT importing TanStack Start. An import of the app or of | ||
| 73 | // `@tanstack/react-start` here silently reintroduces the ~3.1s cold-isolate | ||
| 74 | // `loadEntries` cost this module exists to avoid, and nothing else would | ||
| 75 | // catch it — the behavior stays correct, only slow. | ||
| 76 | it("imports neither TanStack Start nor an app module", () => { | ||
| 77 | const source = readFileSync(new URL("./passthrough.ts", import.meta.url), "utf8"); | ||
| 78 | const imports = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]); | ||
| 79 | expect(imports.length).toBeGreaterThan(0); | ||
| 80 | for (const specifier of imports) { | ||
| 81 | expect(specifier).not.toMatch(/@tanstack/); | ||
| 82 | expect(specifier).not.toMatch(/^\.\.\//); | ||
| 83 | } | ||
| 84 | }); | ||
| 85 | }); | ||
| @@ -0,0 +1,138 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Pure passthrough proxies — dispatched from the Worker entry, before Start. | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // `/docs` and the PostHog proxy forward to an external origin and never touch | ||
| 6 | // the router, React, or the Effect app. They lived in Start's request | ||
| 7 | // middleware, which meant each one still paid Start's lazy `loadEntries` | ||
| 8 | // import of the whole server graph before it could forward a request. | ||
| 9 | // | ||
| 10 | // Measured on production (2026-08-18), splitting the two costs apart on a cold | ||
| 11 | // isolate: the graph import is p50 **3.1s** while the request's own work is | ||
| 12 | // p50 **33ms**. And essentially every request is cold — `worker.dispatch` ran | ||
| 13 | // 1,666 requests across 1,608 isolates (1.04 req/isolate), because `/mcp` | ||
| 14 | // dispatches before `fetchHandler` and so never warms the graph. A `/docs` | ||
| 15 | // page took 3-6s through the Worker against 0.098s straight from the upstream. | ||
| 16 | // | ||
| 17 | // So these move to the Worker entry, exactly as marketing did: classify and | ||
| 18 | // forward before anything imports Start. This module therefore must NOT import | ||
| 19 | // `@tanstack/react-start` or any app module — that import is the cost it | ||
| 20 | // exists to avoid. | ||
| 21 | // | ||
| 22 | // The middleware wrappers in `./docs` and `./posthog` stay registered. In the | ||
| 23 | // deployed Worker they become unreachable (server.ts answers first), but they | ||
| 24 | // keep the behavior identical on any host that reaches Start by another entry | ||
| 25 | // (local dev), and they source their matching from here so the two can't drift. | ||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | |||
| 28 | import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; | ||
| 29 | |||
| 30 | const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; | ||
| 31 | /** PostHog's US ingest origin. Exported because the server-side feature-flag | ||
| 32 | * gate (`../analytics/ema-rollout`) calls the same origin directly, and the | ||
| 33 | * two must not be able to drift onto different PostHog regions. */ | ||
| 34 | export const POSTHOG_INGEST_HOST = "us.i.posthog.com"; | ||
| 35 | const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; | ||
| 36 | |||
| 37 | export const POSTHOG_PROXY_PATH = `/api/${( | ||
| 38 | import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a" | ||
| 39 | ).replace(/^\/+|\/+$/g, "")}`; | ||
| 40 | |||
| 41 | // The proxy fetch gets its own client span: `/docs` requests otherwise render | ||
| 42 | // as a single opaque server span, and during the Aug 2026 regression there | ||
| 43 | // was no way to tell upstream (Mintlify/Vercel) latency from worker-side | ||
| 44 | // dispatch cost. The noop tracer applies when no provider is installed | ||
| 45 | // (local dev without AXIOM_TOKEN), so this is free there. | ||
| 46 | const tracer = trace.getTracer("executor-cloud-docs-proxy"); | ||
| 47 | |||
| 48 | export const isDocsPath = (pathname: string): boolean => | ||
| 49 | pathname === "/docs" || pathname.startsWith("/docs/"); | ||
| 50 | |||
| 51 | export const isPosthogPath = (pathname: string): boolean => | ||
| 52 | pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`); | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Build the upstream request for an already-classified `/docs` path. Caller | ||
| 56 | * guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the | ||
| 57 | * forwarding headers, preserving method, body, path, and query. | ||
| 58 | */ | ||
| 59 | export const buildDocsUpstream = (request: Request): Request => { | ||
| 60 | const url = new URL(request.url); | ||
| 61 | const forwardedHost = url.host; | ||
| 62 | |||
| 63 | url.hostname = DOCS_UPSTREAM_HOST; | ||
| 64 | url.protocol = "https:"; | ||
| 65 | url.port = ""; | ||
| 66 | |||
| 67 | const upstream = new Request(url, request); | ||
| 68 | // Mintlify keys canonical links off the public host; tell it the real one. | ||
| 69 | upstream.headers.set("X-Forwarded-Host", forwardedHost); | ||
| 70 | upstream.headers.set("X-Forwarded-Proto", "https"); | ||
| 71 | // Never leak the executor.sh session cookie to the docs origin. | ||
| 72 | upstream.headers.delete("cookie"); | ||
| 73 | return upstream; | ||
| 74 | }; | ||
| 75 | |||
| 76 | /** Build the upstream request for an already-classified PostHog proxy path. */ | ||
| 77 | export const buildPosthogUpstream = (request: Request, pathname: string): Request => { | ||
| 78 | const url = new URL(request.url); | ||
| 79 | url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) | ||
| 80 | ? POSTHOG_ASSETS_HOST | ||
| 81 | : POSTHOG_INGEST_HOST; | ||
| 82 | url.protocol = "https:"; | ||
| 83 | url.port = ""; | ||
| 84 | url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; | ||
| 85 | |||
| 86 | const upstream = new Request(url, request); | ||
| 87 | upstream.headers.delete("cookie"); | ||
| 88 | return upstream; | ||
| 89 | }; | ||
| 90 | |||
| 91 | export const docsProxyResponse = (request: Request, pathname: string): Promise<Response> => | ||
| 92 | tracer.startActiveSpan( | ||
| 93 | `http.client ${request.method}`, | ||
| 94 | { | ||
| 95 | kind: SpanKind.CLIENT, | ||
| 96 | attributes: { | ||
| 97 | "server.address": DOCS_UPSTREAM_HOST, | ||
| 98 | "url.path": pathname, | ||
| 99 | "http.request.method": request.method, | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | async (span) => { | ||
| 103 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged | ||
| 104 | try { | ||
| 105 | const response = await fetch(buildDocsUpstream(request)); | ||
| 106 | span.setAttribute("http.response.status_code", response.status); | ||
| 107 | if (response.status >= 500) { | ||
| 108 | span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); | ||
| 109 | } | ||
| 110 | return response; | ||
| 111 | } catch (err) { | ||
| 112 | // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below | ||
| 113 | const cause = err instanceof Error ? err : String(err); | ||
| 114 | span.recordException(cause); | ||
| 115 | // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above | ||
| 116 | const message = typeof cause === "string" ? cause : cause.message; | ||
| 117 | span.setStatus({ code: SpanStatusCode.ERROR, message }); | ||
| 118 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler | ||
| 119 | throw err; | ||
| 120 | } finally { | ||
| 121 | span.end(); | ||
| 122 | } | ||
| 123 | }, | ||
| 124 | ); | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Answer a pure passthrough request without loading the Start server graph. | ||
| 128 | * Returns `null` when the request belongs to the app, so the caller falls | ||
| 129 | * through to normal dispatch. | ||
| 130 | */ | ||
| 131 | export const passthroughResponse = ( | ||
| 132 | request: Request, | ||
| 133 | pathname: string, | ||
| 134 | ): Promise<Response> | null => { | ||
| 135 | if (isDocsPath(pathname)) return docsProxyResponse(request, pathname); | ||
| 136 | if (isPosthogPath(pathname)) return fetch(buildPosthogUpstream(request, pathname)); | ||
| 137 | return null; | ||
| 138 | }; | ||
| @@ -3,33 +3,20 @@ · 2 unmodified lines | |||
| 3 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps | 3 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps |
| 4 | // events flowing past adblockers that match *.posthog.com. See | 4 | // events flowing past adblockers that match *.posthog.com. See |
| 5 | // https://posthog.com/docs/advanced/proxy/cloudflare | 5 | // https://posthog.com/docs/advanced/proxy/cloudflare |
| 6 | // | ||
| 7 | // The matching and forwarding live in `./passthrough`, which server.ts | ||
| 8 | // dispatches BEFORE Start loads (a proxy must not pay for the server graph). | ||
| 9 | // This middleware stays registered so hosts that reach Start by another entry | ||
| 10 | // keep identical behavior; in the deployed Worker it is unreachable. | ||
| 6 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| 7 | 12 | ||
| 8 | import { createMiddleware } from "@tanstack/react-start"; | 13 | import { createMiddleware } from "@tanstack/react-start"; |
| 9 | 14 | ||
| 10 | const POSTHOG_INGEST_HOST = "us.i.posthog.com"; | 15 | import { buildPosthogUpstream, isPosthogPath } from "./passthrough"; |
| 11 | const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; | ||
| 12 | const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace( | ||
| 13 | /^\/+|\/+$/g, | ||
| 14 | "", | ||
| 15 | )}`; | ||
| 16 | 16 | ||
| 17 | export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( | 17 | export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( |
| 18 | ({ pathname, request, next }) => { | 18 | ({ pathname, request, next }) => { |
| 19 | if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { | 19 | if (!isPosthogPath(pathname)) return next(); |
| 20 | return next(); | 20 | return fetch(buildPosthogUpstream(request, pathname)); |
| 21 | } | ||
| 22 | |||
| 23 | const url = new URL(request.url); | ||
| 24 | url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) | ||
| 25 | ? POSTHOG_ASSETS_HOST | ||
| 26 | : POSTHOG_INGEST_HOST; | ||
| 27 | url.protocol = "https:"; | ||
| 28 | url.port = ""; | ||
| 29 | url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; | ||
| 30 | |||
| 31 | const upstream = new Request(url, request); | ||
| 32 | upstream.headers.delete("cookie"); | ||
| 33 | return fetch(upstream); | ||
| 34 | }, | 21 | }, |
| 35 | ); | 22 | ); |
| @@ -100,10 +100,17 @@ export const withPreExecutionGate = <E extends Cause.YieldableError>( · 99 unmodified lines | |||
| 100 | ), | 100 | ), |
| 101 | // resume is never gated: paused executions must be able to complete. | 101 | // resume is never gated: paused executions must be able to complete. |
| 102 | resume: (executionId, response) => engine.resume(executionId, response), | 102 | resume: (executionId, response) => engine.resume(executionId, response), |
| 103 | // Optional member, so it must be forwarded explicitly — a decorator that | ||
| 104 | // rebuilds the object literal drops it, and the host then reads every settled | ||
| 105 | // execution as "never existed". | ||
| 106 | isExecutionSettled: engine.isExecutionSettled, | ||
| 103 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), | 107 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), |
| 104 | pausedExecutionCount: () => engine.pausedExecutionCount(), | 108 | pausedExecutionCount: () => engine.pausedExecutionCount(), |
| 105 | hasPausedExecutions: () => engine.hasPausedExecutions(), | 109 | hasPausedExecutions: () => engine.hasPausedExecutions(), |
| 106 | getDescription: engine.getDescription, | 110 | getDescription: engine.getDescription, |
| 111 | // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers, | ||
| 112 | // so the host's request-scope teardown has to reach through this decorator. | ||
| 113 | shutdown: engine.shutdown, | ||
| 107 | }); | 114 | }); |
| 108 | 115 | ||
| 109 | // --------------------------------------------------------------------------- | 116 | // --------------------------------------------------------------------------- |
| @@ -0,0 +1,435 @@ | |||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { env } from "cloudflare:workers"; | ||
| 3 | import { Data, Effect } from "effect"; | ||
| 4 | import type * as Tracer from "effect/Tracer"; | ||
| 5 | |||
| 6 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | ExecutionRateLimiterDO, | ||
| 10 | makeCloudExecutionRateLimiter, | ||
| 11 | makeExecutionRateLimiter, | ||
| 12 | RateLimitCounterError, | ||
| 13 | } from "./execution-rate-limit"; | ||
| 14 | import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; | ||
| 15 | |||
| 16 | const ORG = "org_test"; | ||
| 17 | |||
| 18 | /** Stands in for whatever the real lookups fail with (Autumn down, DO down). */ | ||
| 19 | class UpstreamDownError extends Data.TaggedError("UpstreamDownError")<{ | ||
| 20 | readonly which: string; | ||
| 21 | }> {} | ||
| 22 | |||
| 23 | // A stand-in engine: `execute` resolves to a marker, so a test tells an allowed | ||
| 24 | // execution (marker) from a blocked one (the gate's error result) by which of | ||
| 25 | // the two came back. A blocked decision never reaches the engine at all. | ||
| 26 | const engineStub: ExecutionEngine = { | ||
| 27 | execute: () => Effect.succeed({ result: "ran" }), | ||
| 28 | executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ran" } }), | ||
| 29 | resume: () => Effect.succeed(null), | ||
| 30 | getPausedExecution: () => Effect.succeed(null), | ||
| 31 | pausedExecutionCount: () => Effect.succeed(0), | ||
| 32 | hasPausedExecutions: () => Effect.succeed(false), | ||
| 33 | getDescription: Effect.succeed("stub"), | ||
| 34 | // The stub forks nothing, so there is no sandbox fiber to end. | ||
| 35 | shutdown: Effect.void, | ||
| 36 | }; | ||
| 37 | |||
| 38 | /** Counter that hands out a caller-controlled sequence of counts. */ | ||
| 39 | const countingIncrement = (counts: ReadonlyArray<number>) => { | ||
| 40 | let calls = 0; | ||
| 41 | return () => Effect.succeed(counts[Math.min(calls++, counts.length - 1)] ?? 0); | ||
| 42 | }; | ||
| 43 | |||
| 44 | const runExecute = (limiter: ReturnType<typeof makeExecutionRateLimiter>) => | ||
| 45 | Effect.runPromise( | ||
| 46 | limiter.decorate(ORG, engineStub).execute("code", { | ||
| 47 | // Never invoked: the stub ignores it, and a blocked execution never runs. | ||
| 48 | onElicitation: () => Effect.die("elicitation is not exercised here"), | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | describe("execution rate limiter — paid exemption", () => { | ||
| 53 | it("allows executions under the cap without consulting the exemption", async () => { | ||
| 54 | let exemptionCalls = 0; | ||
| 55 | const limiter = makeExecutionRateLimiter(countingIncrement([1]), { | ||
| 56 | limit: 10, | ||
| 57 | isExempt: () => { | ||
| 58 | exemptionCalls += 1; | ||
| 59 | return Effect.succeed(false); | ||
| 60 | }, | ||
| 61 | }); | ||
| 62 | |||
| 63 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 64 | // The whole point of resolving lazily: the common path costs no lookup. | ||
| 65 | expect(exemptionCalls).toBe(0); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("blocks a non-exempt org over the cap", async () => { | ||
| 69 | const limiter = makeExecutionRateLimiter(countingIncrement([11]), { | ||
| 70 | limit: 10, | ||
| 71 | isExempt: () => Effect.succeed(false), | ||
| 72 | }); | ||
| 73 | |||
| 74 | expect(await runExecute(limiter)).toMatchObject({ | ||
| 75 | result: null, | ||
| 76 | error: RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("allows an exempt org over the cap", async () => { | ||
| 81 | const limiter = makeExecutionRateLimiter(countingIncrement([11]), { | ||
| 82 | limit: 10, | ||
| 83 | isExempt: () => Effect.succeed(true), | ||
| 84 | }); | ||
| 85 | |||
| 86 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("caches the exemption so a paid org past the cap looks it up once", async () => { | ||
| 90 | let exemptionCalls = 0; | ||
| 91 | const limiter = makeExecutionRateLimiter(countingIncrement([11, 12, 13]), { | ||
| 92 | limit: 10, | ||
| 93 | exemptionTtlMs: 60_000, | ||
| 94 | now: () => 1_000, | ||
| 95 | isExempt: () => { | ||
| 96 | exemptionCalls += 1; | ||
| 97 | return Effect.succeed(true); | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | |||
| 101 | for (let i = 0; i < 3; i += 1) | ||
| 102 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 103 | expect(exemptionCalls).toBe(1); | ||
| 104 | }); | ||
| 105 | |||
| 106 | it("re-resolves once the cached exemption expires", async () => { | ||
| 107 | let exemptionCalls = 0; | ||
| 108 | let clock = 1_000; | ||
| 109 | const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), { | ||
| 110 | limit: 10, | ||
| 111 | exemptionTtlMs: 1_000, | ||
| 112 | now: () => clock, | ||
| 113 | isExempt: () => { | ||
| 114 | exemptionCalls += 1; | ||
| 115 | return Effect.succeed(true); | ||
| 116 | }, | ||
| 117 | }); | ||
| 118 | |||
| 119 | await runExecute(limiter); | ||
| 120 | clock += 5_000; | ||
| 121 | await runExecute(limiter); | ||
| 122 | expect(exemptionCalls).toBe(2); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("blocks when the exemption cannot be resolved and nothing is cached", async () => { | ||
| 126 | // Deliberately NOT fail-open: an unresolvable exemption during an Autumn | ||
| 127 | // outage must not switch the backstop off, which is the one scenario it | ||
| 128 | // exists to cover. | ||
| 129 | const limiter = makeExecutionRateLimiter(countingIncrement([11]), { | ||
| 130 | limit: 10, | ||
| 131 | isExempt: () => Effect.fail(new UpstreamDownError({ which: "autumn" })), | ||
| 132 | }); | ||
| 133 | |||
| 134 | expect(await runExecute(limiter)).toMatchObject({ | ||
| 135 | result: null, | ||
| 136 | error: RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("honours a stale exemption when a later lookup fails", async () => { | ||
| 141 | let clock = 1_000; | ||
| 142 | let shouldFail = false; | ||
| 143 | const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), { | ||
| 144 | limit: 10, | ||
| 145 | exemptionTtlMs: 1_000, | ||
| 146 | now: () => clock, | ||
| 147 | isExempt: () => | ||
| 148 | shouldFail ? Effect.fail(new UpstreamDownError({ which: "autumn" })) : Effect.succeed(true), | ||
| 149 | }); | ||
| 150 | |||
| 151 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 152 | |||
| 153 | // Cache expires and Autumn is now unreachable: the known-paid org keeps | ||
| 154 | // running rather than getting blocked mid-workload by a blip. | ||
| 155 | clock += 5_000; | ||
| 156 | shouldFail = true; | ||
| 157 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("fails open when the counter itself is unreachable", async () => { | ||
| 161 | const limiter = makeExecutionRateLimiter( | ||
| 162 | (organizationId) => | ||
| 163 | Effect.fail( | ||
| 164 | new RateLimitCounterError({ | ||
| 165 | organizationId, | ||
| 166 | code: "unknown", | ||
| 167 | reason: "counter DO unreachable", | ||
| 168 | cause: null, | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | { | ||
| 172 | limit: 10, | ||
| 173 | isExempt: () => Effect.succeed(false), | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | |||
| 177 | expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it("applies the cap when no exemption predicate is wired", async () => { | ||
| 181 | const limiter = makeExecutionRateLimiter(countingIncrement([11]), { limit: 10 }); | ||
| 182 | |||
| 183 | expect(await runExecute(limiter)).toMatchObject({ | ||
| 184 | result: null, | ||
| 185 | error: RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 186 | }); | ||
| 187 | }); | ||
| 188 | }); | ||
| 189 | |||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | // Counter observability — the production DO wiring. | ||
| 192 | // | ||
| 193 | // The counter increment used to be a bare one-argument `Effect.tryPromise` | ||
| 194 | // with no span, so a Durable Object fault reached error reporting as | ||
| 195 | // `UnknownError: An error occurred in Effect.tryPromise` with no application | ||
| 196 | // frames, and the 2s check budget was invisible in traces. These tests pin | ||
| 197 | // the named spans, the typed/classified failure, and the timeout override — | ||
| 198 | // all read through the REAL `makeCloudExecutionRateLimiter` wiring against a | ||
| 199 | // fake `EXECUTION_RATE_LIMITER` binding. | ||
| 200 | // --------------------------------------------------------------------------- | ||
| 201 | |||
| 202 | type RecordedSpan = { | ||
| 203 | readonly name: string; | ||
| 204 | readonly attributes: Map<string, unknown>; | ||
| 205 | }; | ||
| 206 | |||
| 207 | /** A tracer that keeps every span it is asked to open, with its attributes. */ | ||
| 208 | const recordingTracer = (recorded: Array<RecordedSpan>): Tracer.Tracer => { | ||
| 209 | let nextId = 1; | ||
| 210 | return { | ||
| 211 | span: (options) => { | ||
| 212 | let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; | ||
| 213 | const attributes = new Map<string, unknown>(); | ||
| 214 | recorded.push({ name: options.name, attributes }); | ||
| 215 | const id = String(nextId++).padStart(16, "0"); | ||
| 216 | return { | ||
| 217 | _tag: "Span", | ||
| 218 | name: options.name, | ||
| 219 | spanId: id, | ||
| 220 | traceId: "00000000000000000000000000000001", | ||
| 221 | parent: options.parent, | ||
| 222 | annotations: options.annotations, | ||
| 223 | get status() { | ||
| 224 | return status; | ||
| 225 | }, | ||
| 226 | attributes, | ||
| 227 | links: options.links, | ||
| 228 | sampled: options.sampled, | ||
| 229 | kind: options.kind, | ||
| 230 | end: (endTime, exit) => { | ||
| 231 | status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; | ||
| 232 | }, | ||
| 233 | attribute: (key, value) => { | ||
| 234 | attributes.set(key, value); | ||
| 235 | }, | ||
| 236 | event: () => undefined, | ||
| 237 | addLinks: () => undefined, | ||
| 238 | }; | ||
| 239 | }, | ||
| 240 | }; | ||
| 241 | }; | ||
| 242 | |||
| 243 | const spanNamed = (recorded: ReadonlyArray<RecordedSpan>, name: string): RecordedSpan => { | ||
| 244 | const span = recorded.find((candidate) => candidate.name === name); | ||
| 245 | expect( | ||
| 246 | span, | ||
| 247 | `a span named ${name} is recorded (got: ${recorded.map((s) => s.name).join(", ") || "none"})`, | ||
| 248 | ).toBeDefined(); | ||
| 249 | return span ?? { name, attributes: new Map() }; | ||
| 250 | }; | ||
| 251 | |||
| 252 | /** A counter-DO namespace whose single RPC method behaves as the test says. */ | ||
| 253 | const namespaceReturning = (increment: () => Promise<number>) => ({ | ||
| 254 | idFromName: (name: string) => ({ toString: () => name }), | ||
| 255 | get: () => ({ increment }), | ||
| 256 | }); | ||
| 257 | |||
| 258 | /** The three worker vars the production limiter reads at construction. */ | ||
| 259 | type CounterEnv = { | ||
| 260 | EXECUTION_RATE_LIMITER?: unknown; | ||
| 261 | EXECUTION_RATE_LIMIT_PER_HOUR?: string; | ||
| 262 | EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string; | ||
| 263 | }; | ||
| 264 | |||
| 265 | const cloudEnv: CounterEnv = env; | ||
| 266 | const savedEnv: CounterEnv = { ...cloudEnv }; | ||
| 267 | |||
| 268 | // Restore rather than leak: the limiter is built from the worker env, and a | ||
| 269 | // stale binding or budget would silently retune a later test. | ||
| 270 | afterEach(() => { | ||
| 271 | delete cloudEnv.EXECUTION_RATE_LIMITER; | ||
| 272 | delete cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR; | ||
| 273 | delete cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; | ||
| 274 | Object.assign(cloudEnv, savedEnv); | ||
| 275 | }); | ||
| 276 | |||
| 277 | // The exact platform fault behind the production issue: Cloudflare resets the | ||
| 278 | // object and the RPC rejects with a plain Error. The reference id is synthetic. | ||
| 279 | const DO_STORAGE_RESET = | ||
| 280 | "Internal error in Durable Object storage caused object to be reset; reference = 0000000000000000"; | ||
| 281 | |||
| 282 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: the Durable Object RPC is a promise that rejects with a platform Error; the test double has to fail the same way for the classification to mean anything | ||
| 283 | const rejectStorageReset = (): Promise<number> => Promise.reject(new Error(DO_STORAGE_RESET)); | ||
| 284 | |||
| 285 | /** Build the production limiter against a fake binding and worker env. */ | ||
| 286 | const cloudLimiter = (options: { | ||
| 287 | readonly namespace: ReturnType<typeof namespaceReturning>; | ||
| 288 | readonly limit: string; | ||
| 289 | readonly timeoutMs?: string; | ||
| 290 | }): ReturnType<typeof makeExecutionRateLimiter> => { | ||
| 291 | cloudEnv.EXECUTION_RATE_LIMITER = options.namespace; | ||
| 292 | cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR = options.limit; | ||
| 293 | if (options.timeoutMs !== undefined) | ||
| 294 | cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS = options.timeoutMs; | ||
| 295 | return makeCloudExecutionRateLimiter(() => Effect.succeed(false)); | ||
| 296 | }; | ||
| 297 | |||
| 298 | const runExecuteTraced = ( | ||
| 299 | limiter: ReturnType<typeof makeExecutionRateLimiter>, | ||
| 300 | recorded: Array<RecordedSpan>, | ||
| 301 | ) => | ||
| 302 | Effect.runPromise( | ||
| 303 | limiter | ||
| 304 | .decorate(ORG, engineStub) | ||
| 305 | .execute("code", { onElicitation: () => Effect.die("elicitation is not exercised here") }) | ||
| 306 | .pipe(Effect.withTracer(recordingTracer(recorded))), | ||
| 307 | ); | ||
| 308 | |||
| 309 | describe("execution rate limiter — counter observability", () => { | ||
| 310 | it("reports a counter-DO fault as a typed, classified error on a named span", async () => { | ||
| 311 | const recorded: Array<RecordedSpan> = []; | ||
| 312 | const limiter = cloudLimiter({ | ||
| 313 | namespace: namespaceReturning(rejectStorageReset), | ||
| 314 | limit: "10", | ||
| 315 | }); | ||
| 316 | const result = await runExecuteTraced(limiter, recorded); | ||
| 317 | |||
| 318 | // Fail-open semantics are unchanged: a broken counter never blocks. | ||
| 319 | expect(result).toMatchObject({ result: "ran" }); | ||
| 320 | |||
| 321 | const increment = spanNamed(recorded, "rate_limit.increment"); | ||
| 322 | expect(increment.attributes.get("rate_limit.counter.error_tag")).toBe("RateLimitCounterError"); | ||
| 323 | expect(increment.attributes.get("rate_limit.counter.error_code")).toBe("storage_reset"); | ||
| 324 | |||
| 325 | const check = spanNamed(recorded, "rate_limit.check"); | ||
| 326 | expect(check.attributes.get("rate_limit.check.failed_open")).toBe(true); | ||
| 327 | expect(check.attributes.get("rate_limit.check.timed_out")).toBe(false); | ||
| 328 | expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCounterError"); | ||
| 329 | }); | ||
| 330 | |||
| 331 | it("honours the EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS override", async () => { | ||
| 332 | const recorded: Array<RecordedSpan> = []; | ||
| 333 | const limiter = cloudLimiter({ | ||
| 334 | // Answers well past the tiny budget, with a count far over the cap. If | ||
| 335 | // the override were ignored the default 2s budget would let that count | ||
| 336 | // through and BLOCK the execution; honouring it times the check out and | ||
| 337 | // fails open instead, so the two outcomes are distinguishable without | ||
| 338 | // measuring wall time. | ||
| 339 | namespace: namespaceReturning( | ||
| 340 | () => new Promise((resolve) => setTimeout(() => resolve(999), 200)), | ||
| 341 | ), | ||
| 342 | limit: "10", | ||
| 343 | timeoutMs: "5", | ||
| 344 | }); | ||
| 345 | const result = await runExecuteTraced(limiter, recorded); | ||
| 346 | |||
| 347 | expect(result).toMatchObject({ result: "ran" }); | ||
| 348 | |||
| 349 | const check = spanNamed(recorded, "rate_limit.check"); | ||
| 350 | expect(check.attributes.get("rate_limit.check.timed_out")).toBe(true); | ||
| 351 | expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCheckTimeoutError"); | ||
| 352 | }); | ||
| 353 | |||
| 354 | // The healthy check's span (count / limit / blocked / failed_open) is NOT | ||
| 355 | // asserted here: the cloud e2e scenario "the rate-limit counter check is | ||
| 356 | // visible in the exported spans" pins it on the real workerd + Durable | ||
| 357 | // Object topology, against the spans the worker actually exports. The two | ||
| 358 | // cases above stay because neither is reachable from the e2e harness — it | ||
| 359 | // has no fault seam for a Durable Object RPC, and the check budget is a | ||
| 360 | // process-wide worker var that the shared cloud boot cannot vary per | ||
| 361 | // scenario without disabling the backstop for the whole run. | ||
| 362 | }); | ||
| 363 | |||
| 364 | // --------------------------------------------------------------------------- | ||
| 365 | // Counter Durable Object | ||
| 366 | // --------------------------------------------------------------------------- | ||
| 367 | |||
| 368 | /** Minimal DO storage that counts the calls the increment path makes. */ | ||
| 369 | const fakeStorage = () => { | ||
| 370 | const values = new Map<string, unknown>(); | ||
| 371 | const calls = { put: 0, setAlarm: 0, deleteAll: 0 }; | ||
| 372 | return { | ||
| 373 | calls, | ||
| 374 | storage: { | ||
| 375 | get: (key: string) => Promise.resolve(values.get(key)), | ||
| 376 | put: (key: string, value: unknown) => { | ||
| 377 | calls.put += 1; | ||
| 378 | values.set(key, value); | ||
| 379 | return Promise.resolve(); | ||
| 380 | }, | ||
| 381 | setAlarm: () => { | ||
| 382 | calls.setAlarm += 1; | ||
| 383 | return Promise.resolve(); | ||
| 384 | }, | ||
| 385 | deleteAll: () => { | ||
| 386 | calls.deleteAll += 1; | ||
| 387 | values.clear(); | ||
| 388 | return Promise.resolve(); | ||
| 389 | }, | ||
| 390 | }, | ||
| 391 | }; | ||
| 392 | }; | ||
| 393 | |||
| 394 | const makeCounter = (storage: ReturnType<typeof fakeStorage>["storage"]) => | ||
| 395 | // oxlint-disable-next-line executor/no-double-cast -- test double: only the four storage methods the counter uses are implemented | ||
| 396 | new ExecutionRateLimiterDO({ storage } as unknown as DurableObjectState, {} as Env); | ||
| 397 | |||
| 398 | describe("execution rate-limit counter DO", () => { | ||
| 399 | it("writes the purge alarm once per window instead of on every increment", async () => { | ||
| 400 | // The alarm only has to outlive the window; rewriting it on every call put | ||
| 401 | // a second durable write on the hot path with the input gate closed. | ||
| 402 | const fake = fakeStorage(); | ||
| 403 | const counter = makeCounter(fake.storage); | ||
| 404 | |||
| 405 | expect(await counter.increment(7)).toBe(1); | ||
| 406 | expect(await counter.increment(7)).toBe(2); | ||
| 407 | expect(await counter.increment(7)).toBe(3); | ||
| 408 | |||
| 409 | expect(fake.calls.put, "every increment still persists the count").toBe(3); | ||
| 410 | expect(fake.calls.setAlarm, "the purge alarm is written once for the window").toBe(1); | ||
| 411 | }); | ||
| 412 | |||
| 413 | it("moves the purge alarm when the window rolls", async () => { | ||
| 414 | const fake = fakeStorage(); | ||
| 415 | const counter = makeCounter(fake.storage); | ||
| 416 | |||
| 417 | await counter.increment(7); | ||
| 418 | await counter.increment(7); | ||
| 419 | expect(await counter.increment(8), "a new window restarts the count").toBe(1); | ||
| 420 | |||
| 421 | expect(fake.calls.setAlarm, "one alarm write per window, not per increment").toBe(2); | ||
| 422 | }); | ||
| 423 | |||
| 424 | it("re-arms the purge alarm after it has fired", async () => { | ||
| 425 | const fake = fakeStorage(); | ||
| 426 | const counter = makeCounter(fake.storage); | ||
| 427 | |||
| 428 | await counter.increment(7); | ||
| 429 | await counter.alarm(); | ||
| 430 | expect(fake.calls.deleteAll, "the alarm purges the counter's storage").toBe(1); | ||
| 431 | |||
| 432 | expect(await counter.increment(7), "the purge reset the window's count").toBe(1); | ||
| 433 | expect(fake.calls.setAlarm, "a purged counter schedules a fresh purge").toBe(2); | ||
| 434 | }); | ||
| 435 | }); | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Per-org execution rate limit — an abuse backstop independent of billing. | 2 | // Per-org execution rate limit — a free-tier abuse backstop. |
| 3 | // | 3 | // |
| 4 | // The balance gate (execution-gate.ts) depends on Autumn and fails open, so a | 4 | // The balance gate (execution-gate.ts) depends on Autumn and fails open, so a |
| 5 | // billing outage plus runaway automation could still run unbounded executions. | 5 | // billing outage plus runaway automation could still run unbounded executions. |
| @@ -8,13 +8,26 @@ · 2 unmodified lines | |||
| 8 | // each MCP session lives in its own DO instance, so an in-memory counter | 8 | // each MCP session lives in its own DO instance, so an in-memory counter |
| 9 | // would be per-session and trivially bypassed by opening more sessions). | 9 | // would be per-session and trivially bypassed by opening more sessions). |
| 10 | // | 10 | // |
| 11 | // Like the balance gate it FAILS OPEN: an unreachable counter DO, a missing | 11 | // Paid organizations are EXEMPT. The cap was sized for free-tier abuse but |
| 12 | // applied to everyone, and on 2026-08-18 it blocked a paying customer | ||
| 13 | // mid-workload — their agent gave up on Executor and routed around it. Paid | ||
| 14 | // usage is what the balance gate and metered overage are for; this backstop | ||
| 15 | // has no business capping it. | ||
| 16 | // | ||
| 17 | // The exemption is resolved ONLY once the counter reports an org over the cap, | ||
| 18 | // so the common path (under the cap) costs the counter increment and nothing | ||
| 19 | // else. `isExempt` is an opaque predicate: this module still names no billing | ||
| 20 | // concept, and the Autumn coupling lives in `execution-stack-metered.ts`, | ||
| 21 | // which already owns that dependency. | ||
| 22 | // | ||
| 23 | // FAIL OPEN applies to the COUNTER: an unreachable counter DO, a missing | ||
| 12 | // binding, or a slow call allows the execution (warn + Sentry). The backstop | 24 | // binding, or a slow call allows the execution (warn + Sentry). The backstop |
| 13 | // must never take executions down with it. | 25 | // must never take executions down with it. An unresolved EXEMPTION is the one |
| 26 | // thing that does not fail open — see `resolveExemption`. | ||
| 14 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 15 | 28 | ||
| 16 | import { DurableObject, env } from "cloudflare:workers"; | 29 | import { DurableObject, env } from "cloudflare:workers"; |
| 17 | import { Data, Effect } from "effect"; | 30 | import { Data, Effect, Predicate } from "effect"; |
| 18 | import type * as Cause from "effect/Cause"; | 31 | import type * as Cause from "effect/Cause"; |
| 19 | 32 | ||
| 20 | import type { ExecutionEngine } from "@executor-js/execution"; | 33 | import type { ExecutionEngine } from "@executor-js/execution"; |
| @@ -25,14 +38,26 @@ import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; · 4 unmodified lines | |||
| 25 | 38 | ||
| 26 | // Fixed window: all executions in the same clock hour share one counter. | 39 | // Fixed window: all executions in the same clock hour share one counter. |
| 27 | export const RATE_LIMIT_WINDOW_MS = 3_600_000; | 40 | export const RATE_LIMIT_WINDOW_MS = 3_600_000; |
| 28 | // Calibration: the heaviest legitimate org runs ~1.1k executions per MONTH, | 41 | // The cap for organizations WITHOUT a paid subscription. |
| 29 | // so 1000 per HOUR is far above any human-driven usage and only trips on | 42 | // |
| 30 | // runaway automation (the incident this backstops: ~18k in 30 days would | 43 | // The original calibration ("the heaviest legitimate org runs ~1.1k executions |
| 31 | // still pass, which is fine — that class of overrun is the balance gate's | 44 | // per MONTH, so 1000 per HOUR is far above any human-driven usage") went stale |
| 32 | // job; this catches tight loops). | 45 | // inside six weeks: by 2026-08-18 a paying org was sustaining ~5.3k executions |
| 46 | // per DAY and crossed this cap in a single hour. Sizing a shared number | ||
| 47 | // against the largest customer is a losing game, so the number no longer tries | ||
| 48 | // to describe them — paid orgs are exempt below, and this now has only | ||
| 49 | // free-tier abuse to cover, which is what it was picked for. | ||
| 33 | export const EXECUTIONS_PER_ORG_PER_HOUR = 1000; | 50 | export const EXECUTIONS_PER_ORG_PER_HOUR = 1000; |
| 34 | // Counter DO slower than this => fail open rather than stall executions. | 51 | // Counter DO slower than this => fail open rather than stall executions. |
| 35 | const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000; | 52 | const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000; |
| 53 | // Exemption lookup slower than this => treat as unresolved. | ||
| 54 | const EXEMPTION_CHECK_TIMEOUT_MS = 2_000; | ||
| 55 | // An org over the cap is checked at most once per TTL rather than once per | ||
| 56 | // execution, so a paid org running far past it doesn't hammer the lookup. | ||
| 57 | const EXEMPTION_CACHE_TTL_MS = 60_000; | ||
| 58 | // Sweep guard, mirroring the balance gate's cache: one long-lived isolate can | ||
| 59 | // serve many orgs. | ||
| 60 | const EXEMPTION_CACHE_MAX_ENTRIES = 10_000; | ||
| 36 | // The DO purges its storage this long after the last increment, so idle orgs | 61 | // The DO purges its storage this long after the last increment, so idle orgs |
| 37 | // cost nothing. Two windows: long enough that an active window never purges. | 62 | // cost nothing. Two windows: long enough that an active window never purges. |
| 38 | const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS; | 63 | const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS; |
| @@ -51,6 +76,80 @@ class RateLimitCheckTimeoutError extends Data.TaggedError("RateLimitCheckTimeout · 12 unmodified lines | |||
| 51 | readonly timeoutMs: number; | 76 | readonly timeoutMs: number; |
| 52 | }> {} | 77 | }> {} |
| 53 | 78 | ||
| 79 | /** | ||
| 80 | * Why a counter DO call failed, as a small closed vocabulary. | ||
| 81 | * | ||
| 82 | * The counter's failures are overwhelmingly transient Cloudflare platform | ||
| 83 | * faults, and they used to arrive at error reporting as one untyped | ||
| 84 | * `UnknownError: An error occurred in Effect.tryPromise` with no application | ||
| 85 | * frames — a group that says nothing and would eventually swallow a real | ||
| 86 | * misconfiguration too. The code is what makes a storage reset (retryable, | ||
| 87 | * expected) distinguishable from an overload or an outright unknown fault. | ||
| 88 | */ | ||
| 89 | export type RateLimitCounterErrorCode = | ||
| 90 | | "storage_reset" | ||
| 91 | | "overloaded" | ||
| 92 | | "exceeded_memory" | ||
| 93 | | "network" | ||
| 94 | | "unknown"; | ||
| 95 | |||
| 96 | /** A counter DO call that failed, carrying the classification and the org. */ | ||
| 97 | export class RateLimitCounterError extends Data.TaggedError("RateLimitCounterError")<{ | ||
| 98 | readonly organizationId: string; | ||
| 99 | readonly code: RateLimitCounterErrorCode; | ||
| 100 | readonly reason: string; | ||
| 101 | readonly cause: unknown; | ||
| 102 | }> {} | ||
| 103 | |||
| 104 | // Cloudflare surfaces these as plain `Error`s with documented message text; | ||
| 105 | // there is no structured code to read, so the message is the only signal. | ||
| 106 | // (A shared `classifyDurableObjectError` would be the right home for this once | ||
| 107 | // one exists.) | ||
| 108 | const counterErrorCode = (reason: string): RateLimitCounterErrorCode => { | ||
| 109 | if (/caused object to be reset/i.test(reason)) return "storage_reset"; | ||
| 110 | if (/overloaded/i.test(reason)) return "overloaded"; | ||
| 111 | if (/exceeded (its )?memory|out of memory/i.test(reason)) return "exceeded_memory"; | ||
| 112 | if (/network connection lost|connection.*(lost|reset)/i.test(reason)) return "network"; | ||
| 113 | return "unknown"; | ||
| 114 | }; | ||
| 115 | |||
| 116 | /** | ||
| 117 | * The fail-open landing: record the outcome on the check span, warn, and allow | ||
| 118 | * the execution. Only failures that are NOT deliberate degradation reach the | ||
| 119 | * error reporter — see the call sites in `decide`. | ||
| 120 | */ | ||
| 121 | const failOpen = ( | ||
| 122 | error: unknown, | ||
| 123 | outcome: { readonly errorTag: string; readonly timedOut: boolean }, | ||
| 124 | ): Effect.Effect<GateDecision> => | ||
| 125 | Effect.gen(function* () { | ||
| 126 | yield* Effect.annotateCurrentSpan({ | ||
| 127 | "rate_limit.blocked": false, | ||
| 128 | "rate_limit.check.failed_open": true, | ||
| 129 | "rate_limit.check.timed_out": outcome.timedOut, | ||
| 130 | "rate_limit.check.error_tag": outcome.errorTag, | ||
| 131 | }); | ||
| 132 | yield* Effect.sync(() => { | ||
| 133 | console.warn("[rate-limit] execution rate limit check failed open:", error); | ||
| 134 | }); | ||
| 135 | if (!outcome.timedOut) yield* captureCauseEffect(error); | ||
| 136 | return { blocked: false } as const satisfies GateDecision; | ||
| 137 | }); | ||
| 138 | |||
| 139 | /** Internal sentinel for an exemption lookup that exceeded its time budget. */ | ||
| 140 | class ExemptionCheckTimeoutError extends Data.TaggedError("ExemptionCheckTimeoutError")<{ | ||
| 141 | readonly timeoutMs: number; | ||
| 142 | }> {} | ||
| 143 | |||
| 144 | /** | ||
| 145 | * Whether an organization is exempt from the cap. Production passes a paid- | ||
| 146 | * subscription check; keeping it an opaque predicate is what lets this module | ||
| 147 | * stay free of any billing import. | ||
| 148 | */ | ||
| 149 | export type ExecutionRateLimitExemption = ( | ||
| 150 | organizationId: string, | ||
| 151 | ) => Effect.Effect<boolean, unknown>; | ||
| 152 | |||
| 54 | // --------------------------------------------------------------------------- | 153 | // --------------------------------------------------------------------------- |
| 55 | // Counter Durable Object — one instance per organization (idFromName(orgId)). | 154 | // Counter Durable Object — one instance per organization (idFromName(orgId)). |
| 56 | // Stores a single { windowId, count } record: an increment in a new window | 155 | // Stores a single { windowId, count } record: an increment in a new window |
| @@ -67,6 +166,12 @@ type WindowRecord = { · 10 unmodified lines | |||
| 67 | 166 | ||
| 68 | export class ExecutionRateLimiterDO extends DurableObject { | 167 | export class ExecutionRateLimiterDO extends DurableObject { |
| 69 | private readonly counterStorage: DurableObjectState["storage"]; | 168 | private readonly counterStorage: DurableObjectState["storage"]; |
| 169 | /** | ||
| 170 | * The window this instance has already armed the purge alarm for. In-memory | ||
| 171 | * on purpose: it costs no storage read, and a fresh instance (eviction, cold | ||
| 172 | * start) simply re-arms on its first increment. | ||
| 173 | */ | ||
| 174 | private purgeArmedForWindow: number | null = null; | ||
| 70 | 175 | ||
| 71 | constructor(ctx: DurableObjectState, doEnv: Env) { | 176 | constructor(ctx: DurableObjectState, doEnv: Env) { |
| 72 | super(ctx, doEnv); | 177 | super(ctx, doEnv); |
| @@ -80,11 +185,21 @@ export class ExecutionRateLimiterDO extends DurableObject { · 7 unmodified lines | |||
| 80 | const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY); | 185 | const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY); |
| 81 | const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; | 186 | const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; |
| 82 | await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count }); | 187 | await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count }); |
| 83 | await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); | 188 | // The alarm only has to outlive the window, and it is set two windows out, |
| 189 | // so once per window is enough — rewriting it on every increment put a | ||
| 190 | // second durable write and an alarm-manager update on the hot path of | ||
| 191 | // every execution, with the input gate closed across all three. `count` | ||
| 192 | // back at 1 means the window rolled (or a purge already ran), so the | ||
| 193 | // deadline moves with it. | ||
| 194 | if (count === 1 || this.purgeArmedForWindow !== windowId) { | ||
| 195 | await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); | ||
| 196 | this.purgeArmedForWindow = windowId; | ||
| 197 | } | ||
| 84 | return count; | 198 | return count; |
| 85 | } | 199 | } |
| 86 | 200 | ||
| 87 | async alarm(): Promise<void> { | 201 | async alarm(): Promise<void> { |
| 202 | this.purgeArmedForWindow = null; | ||
| 88 | await this.counterStorage.deleteAll(); | 203 | await this.counterStorage.deleteAll(); |
| 89 | } | 204 | } |
| 90 | } | 205 | } |
| @@ -93,11 +208,17 @@ export class ExecutionRateLimiterDO extends DurableObject { · 2 unmodified lines | |||
| 93 | // Client | 208 | // Client |
| 94 | // --------------------------------------------------------------------------- | 209 | // --------------------------------------------------------------------------- |
| 95 | 210 | ||
| 96 | /** Count one execution for (organizationId, windowId); returns the new count. */ | 211 | /** |
| 212 | * Count one execution for (organizationId, windowId); returns the new count. | ||
| 213 | * | ||
| 214 | * The failure channel is typed rather than `unknown` so the fail-open path can | ||
| 215 | * tell a counter fault from a blown budget by tag, and so error reporting | ||
| 216 | * groups by cause instead of by one opaque `UnknownError`. | ||
| 217 | */ | ||
| 97 | export type RateLimitIncrement = ( | 218 | export type RateLimitIncrement = ( |
| 98 | organizationId: string, | 219 | organizationId: string, |
| 99 | windowId: number, | 220 | windowId: number, |
| 100 | ) => Effect.Effect<number, unknown>; | 221 | ) => Effect.Effect<number, RateLimitCounterError>; |
| 101 | 222 | ||
| 102 | export type ExecutionRateLimiter = { | 223 | export type ExecutionRateLimiter = { |
| 103 | readonly decorate: <E extends Cause.YieldableError>( | 224 | readonly decorate: <E extends Cause.YieldableError>( |
| @@ -119,12 +240,76 @@ export const makeExecutionRateLimiter = ( · 15 unmodified lines | |||
| 119 | readonly windowMs?: number; | 240 | readonly windowMs?: number; |
| 120 | readonly timeoutMs?: number; | 241 | readonly timeoutMs?: number; |
| 121 | readonly now?: () => number; | 242 | readonly now?: () => number; |
| 243 | readonly isExempt?: ExecutionRateLimitExemption; | ||
| 244 | readonly exemptionTtlMs?: number; | ||
| 122 | }, | 245 | }, |
| 123 | ): ExecutionRateLimiter => { | 246 | ): ExecutionRateLimiter => { |
| 124 | const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR; | 247 | const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR; |
| 125 | const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS; | 248 | const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS; |
| 126 | const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS; | 249 | const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS; |
| 127 | const now = options?.now ?? Date.now; | 250 | const now = options?.now ?? Date.now; |
| 251 | const isExempt = options?.isExempt; | ||
| 252 | const exemptionTtlMs = options?.exemptionTtlMs ?? EXEMPTION_CACHE_TTL_MS; | ||
| 253 | |||
| 254 | const exemptionCache = new Map< | ||
| 255 | string, | ||
| 256 | { readonly exempt: boolean; readonly expiresAtMs: number } | ||
| 257 | >([]); | ||
| 258 | |||
| 259 | const writeExemptionCache = (organizationId: string, exempt: boolean, nowMs: number): void => { | ||
| 260 | if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) { | ||
| 261 | for (const [key, entry] of exemptionCache) { | ||
| 262 | if (entry.expiresAtMs <= nowMs) exemptionCache.delete(key); | ||
| 263 | } | ||
| 264 | // Still saturated after dropping expired entries: reset rather than grow. | ||
| 265 | if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) exemptionCache.clear(); | ||
| 266 | } | ||
| 267 | exemptionCache.set(organizationId, { exempt, expiresAtMs: nowMs + exemptionTtlMs }); | ||
| 268 | }; | ||
| 269 | |||
| 270 | /** | ||
| 271 | * Resolved only for orgs already over the cap, so the lookup never touches | ||
| 272 | * the common path. | ||
| 273 | * | ||
| 274 | * This is the one place that does NOT fail open. The balance gate already | ||
| 275 | * allows executions when Autumn is unreachable; if the exemption did too, | ||
| 276 | * an Autumn outage would switch this backstop off entirely — precisely the | ||
| 277 | * "billing outage plus runaway automation" case it exists to cover. A stale | ||
| 278 | * positive is honoured ahead of that fallback, so a blip can't flip a | ||
| 279 | * known-paid org into a block mid-workload. | ||
| 280 | */ | ||
| 281 | const resolveExemption = (organizationId: string): Effect.Effect<boolean> => | ||
| 282 | Effect.suspend(() => { | ||
| 283 | if (!isExempt) return Effect.succeed(false); | ||
| 284 | const nowMs = now(); | ||
| 285 | const cached = exemptionCache.get(organizationId); | ||
| 286 | if (cached && cached.expiresAtMs > nowMs) return Effect.succeed(cached.exempt); | ||
| 287 | return isExempt(organizationId).pipe( | ||
| 288 | Effect.timeoutOrElse({ | ||
| 289 | duration: `${EXEMPTION_CHECK_TIMEOUT_MS} millis`, | ||
| 290 | orElse: () => | ||
| 291 | Effect.fail(new ExemptionCheckTimeoutError({ timeoutMs: EXEMPTION_CHECK_TIMEOUT_MS })), | ||
| 292 | }), | ||
| 293 | Effect.map((exempt) => { | ||
| 294 | writeExemptionCache(organizationId, exempt, nowMs); | ||
| 295 | return exempt; | ||
| 296 | }), | ||
| 297 | Effect.catch((error: unknown) => | ||
| 298 | Effect.gen(function* () { | ||
| 299 | yield* Effect.sync(() => { | ||
| 300 | console.warn( | ||
| 301 | `[rate-limit] exemption lookup failed for ${organizationId}; treating as ${ | ||
| 302 | cached ? "last known" : "not exempt" | ||
| 303 | }:`, | ||
| 304 | error, | ||
| 305 | ); | ||
| 306 | }); | ||
| 307 | yield* captureCauseEffect(error); | ||
| 308 | return cached?.exempt ?? false; | ||
| 309 | }), | ||
| 310 | ), | ||
| 311 | ); | ||
| 312 | }); | ||
| 128 | 313 | ||
| 129 | const decide = (organizationId: string): Effect.Effect<GateDecision> => | 314 | const decide = (organizationId: string): Effect.Effect<GateDecision> => |
| 130 | Effect.suspend(() => { | 315 | Effect.suspend(() => { |
| @@ -134,29 +319,86 @@ export const makeExecutionRateLimiter = ( · 3 unmodified lines | |||
| 134 | duration: `${timeoutMs} millis`, | 319 | duration: `${timeoutMs} millis`, |
| 135 | orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })), | 320 | orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })), |
| 136 | }), | 321 | }), |
| 137 | Effect.map( | 322 | Effect.flatMap((count): Effect.Effect<GateDecision> => { |
| 138 | (count): GateDecision => | 323 | // Under the cap: no exemption lookup, no extra I/O. |
| 139 | count > limit | 324 | if (count <= limit) |
| 140 | ? { | 325 | return Effect.as( |
| 141 | blocked: true, | 326 | Effect.annotateCurrentSpan({ |
| 142 | error: new ExecutionRateLimitExceededError({ | 327 | "rate_limit.count": count, |
| 143 | organizationId, | 328 | "rate_limit.blocked": false, |
| 144 | message: RATE_LIMIT_BLOCKED_MESSAGE, | 329 | "rate_limit.check.failed_open": false, |
| 145 | }), | 330 | }), |
| 146 | } | 331 | { blocked: false }, |
| 147 | : { blocked: false }, | 332 | ); |
| 148 | ), | 333 | return Effect.gen(function* () { |
| 334 | yield* Effect.annotateCurrentSpan({ | ||
| 335 | "rate_limit.count": count, | ||
| 336 | "rate_limit.check.failed_open": false, | ||
| 337 | }); | ||
| 338 | if (yield* resolveExemption(organizationId)) { | ||
| 339 | yield* Effect.annotateCurrentSpan({ | ||
| 340 | "rate_limit.blocked": false, | ||
| 341 | "rate_limit.exempt": true, | ||
| 342 | }); | ||
| 343 | return { blocked: false } as const satisfies GateDecision; | ||
| 344 | } | ||
| 345 | // The only record that the backstop fired. A blocked execution is | ||
| 346 | // never usage-tracked (the gate short-circuits before the tracker) | ||
| 347 | // and deliberately not sent to Sentry — a backstop stopping | ||
| 348 | // runaway automation is expected, not exceptional — so without | ||
| 349 | // this line a blocked org is invisible outside a bug report, which | ||
| 350 | // is how the 2026-08-18 block went unnoticed until a customer | ||
| 351 | // sent a screenshot. | ||
| 352 | yield* Effect.sync(() => { | ||
| 353 | console.warn( | ||
| 354 | `[rate-limit] blocked execution for ${organizationId}: ${count} > ${limit} in window ${windowId}`, | ||
| 355 | ); | ||
| 356 | }); | ||
| 357 | yield* Effect.annotateCurrentSpan({ | ||
| 358 | "rate_limit.blocked": true, | ||
| 359 | "rate_limit.exempt": false, | ||
| 360 | }); | ||
| 361 | return { | ||
| 362 | blocked: true, | ||
| 363 | error: new ExecutionRateLimitExceededError({ | ||
| 364 | organizationId, | ||
| 365 | message: RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 366 | }), | ||
| 367 | } as const satisfies GateDecision; | ||
| 368 | }); | ||
| 369 | }), | ||
| 149 | // FAIL OPEN: the backstop must never block executions because its | 370 | // FAIL OPEN: the backstop must never block executions because its |
| 150 | // counter is unreachable or slow. | 371 | // counter is unreachable or slow. |
| 372 | // | ||
| 373 | // A check that blew its own budget is DELIBERATE degradation, not an | ||
| 374 | // exception — the timeout exists precisely so a slow counter can't | ||
| 375 | // stall a user-facing execution. It is measured on the span | ||
| 376 | // (`rate_limit.check.timed_out`), where a step change in the rate is | ||
| 377 | // alertable, rather than paged per occurrence, which is what buried | ||
| 378 | // real counter failures under one opaque group. Everything else (RPC | ||
| 379 | // faults, a missing binding) still reports. | ||
| 380 | Effect.catchTag("RateLimitCheckTimeoutError", (error) => | ||
| 381 | failOpen(error, { errorTag: "RateLimitCheckTimeoutError", timedOut: true }), | ||
| 382 | ), | ||
| 383 | // A catch-all rather than a second `catchTag`: fail-open is a hard | ||
| 384 | // requirement and must not depend on the failure being one the types | ||
| 385 | // predicted. | ||
| 151 | Effect.catch((error: unknown) => | 386 | Effect.catch((error: unknown) => |
| 152 | Effect.gen(function* () { | 387 | failOpen(error, { |
| 153 | yield* Effect.sync(() => { | 388 | errorTag: Predicate.isTagged(error, "RateLimitCounterError") |
| 154 | console.warn("[rate-limit] execution rate limit check failed open:", error); | 389 | ? "RateLimitCounterError" |
| 155 | }); | 390 | : "unknown", |
| 156 | yield* captureCauseEffect(error); | 391 | timedOut: false, |
| 157 | return { blocked: false } as const satisfies GateDecision; | ||
| 158 | }), | 392 | }), |
| 159 | ), | 393 | ), |
| 394 | Effect.withSpan("rate_limit.check", { | ||
| 395 | attributes: { | ||
| 396 | "rate_limit.organization_id": organizationId, | ||
| 397 | "rate_limit.window_id": windowId, | ||
| 398 | "rate_limit.limit": limit, | ||
| 399 | "rate_limit.check.timeout_ms": timeoutMs, | ||
| 400 | }, | ||
| 401 | }), | ||
| 160 | ); | 402 | ); |
| 161 | }); | 403 | }); |
| 162 | 404 | ||
| @@ -185,8 +427,13 @@ type RateLimiterNamespace = { · 22 unmodified lines | |||
| 185 | * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO. | 427 | * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO. |
| 186 | * When the binding is absent (unit-test workers, older local setups) the | 428 | * When the binding is absent (unit-test workers, older local setups) the |
| 187 | * limiter is disabled: every check passes, logged once at construction. | 429 | * limiter is disabled: every check passes, logged once at construction. |
| 430 | * | ||
| 431 | * `isExempt` decides which orgs the cap skips; production passes a paid- | ||
| 432 | * subscription check from `execution-stack-metered.ts`. | ||
| 188 | */ | 433 | */ |
| 189 | export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { | 434 | export const makeCloudExecutionRateLimiter = ( |
| 435 | isExempt: ExecutionRateLimitExemption, | ||
| 436 | ): ExecutionRateLimiter => { | ||
| 190 | const limit = resolveRateLimit(); | 437 | const limit = resolveRateLimit(); |
| 191 | const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace }) | 438 | const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace }) |
| 192 | .EXECUTION_RATE_LIMITER; | 439 | .EXECUTION_RATE_LIMITER; |
| @@ -196,17 +443,53 @@ export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { · 3 unmodified lines | |||
| 196 | ); | 443 | ); |
| 197 | return makeExecutionRateLimiter(() => Effect.succeed(0)); | 444 | return makeExecutionRateLimiter(() => Effect.succeed(0)); |
| 198 | } | 445 | } |
| 199 | return makeExecutionRateLimiter( | 446 | return makeExecutionRateLimiter(counterIncrement(namespace), { |
| 200 | (organizationId, windowId) => | 447 | limit, |
| 201 | Effect.tryPromise(() => { | 448 | timeoutMs: resolveCheckTimeoutMs(), |
| 449 | isExempt, | ||
| 450 | }); | ||
| 451 | }; | ||
| 452 | |||
| 453 | /** | ||
| 454 | * The counter DO RPC, as a traced and typed increment. | ||
| 455 | * | ||
| 456 | * The span is the whole point: this call is a blocking, cold-startable hop on | ||
| 457 | * the execute hot path, and until it had one nothing about its duration was | ||
| 458 | * measurable — the only evidence it was slow was the fail-open warning 2s | ||
| 459 | * later. The typed error replaces Effect's generic `UnknownError`, which | ||
| 460 | * reported no org, no window, and no hint that a Durable Object was involved. | ||
| 461 | */ | ||
| 462 | const counterIncrement = | ||
| 463 | (namespace: RateLimiterNamespace): RateLimitIncrement => | ||
| 464 | (organizationId, windowId) => | ||
| 465 | Effect.tryPromise({ | ||
| 466 | try: () => { | ||
| 202 | const stub = namespace.get( | 467 | const stub = namespace.get( |
| 203 | namespace.idFromName(organizationId), | 468 | namespace.idFromName(organizationId), |
| 204 | ) as ExecutionRateLimiterStub; | 469 | ) as ExecutionRateLimiterStub; |
| 205 | return stub.increment(windowId); | 470 | return stub.increment(windowId); |
| 471 | }, | ||
| 472 | catch: (cause) => { | ||
| 473 | // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: the Durable Object RPC rejects with a plain platform Error whose message text is the only classification signal Cloudflare gives | ||
| 474 | const reason = cause instanceof Error ? cause.message : String(cause); | ||
| 475 | return new RateLimitCounterError({ | ||
| 476 | organizationId, | ||
| 477 | code: counterErrorCode(reason), | ||
| 478 | reason, | ||
| 479 | cause, | ||
| 480 | }); | ||
| 481 | }, | ||
| 482 | }).pipe( | ||
| 483 | Effect.tapError((error) => | ||
| 484 | Effect.annotateCurrentSpan({ | ||
| 485 | "rate_limit.counter.error_tag": "RateLimitCounterError", | ||
| 486 | "rate_limit.counter.error_code": error.code, | ||
| 487 | }), | ||
| 488 | ), | ||
| 489 | Effect.withSpan("rate_limit.increment", { | ||
| 490 | attributes: { "rate_limit.window_id": windowId }, | ||
| 206 | }), | 491 | }), |
| 207 | { limit }, | 492 | ); |
| 208 | ); | ||
| 209 | }; | ||
| 210 | 493 | ||
| 211 | /** | 494 | /** |
| 212 | * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override | 495 | * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override |
| @@ -220,3 +503,18 @@ const resolveRateLimit = (): number => { · 7 unmodified lines | |||
| 220 | const parsed = Number.parseInt(raw, 10); | 503 | const parsed = Number.parseInt(raw, 10); |
| 221 | return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR; | 504 | return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR; |
| 222 | }; | 505 | }; |
| 506 | |||
| 507 | /** | ||
| 508 | * The counter's time budget: the `EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS` env | ||
| 509 | * override or `RATE_LIMIT_CHECK_TIMEOUT_MS` when it's unset or unparseable. | ||
| 510 | * Same precedent and same purpose as `EXECUTION_RATE_LIMIT_PER_HOUR`: the | ||
| 511 | * production 2s budget can't be blown on demand, so tests set a tiny one to | ||
| 512 | * drive the fail-open path deterministically. Production leaves it unset. | ||
| 513 | */ | ||
| 514 | const resolveCheckTimeoutMs = (): number => { | ||
| 515 | const raw = (env as { EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string }) | ||
| 516 | .EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; | ||
| 517 | if (raw === undefined) return RATE_LIMIT_CHECK_TIMEOUT_MS; | ||
| 518 | const parsed = Number.parseInt(raw, 10); | ||
| 519 | return Number.isInteger(parsed) && parsed > 0 ? parsed : RATE_LIMIT_CHECK_TIMEOUT_MS; | ||
| 520 | }; | ||
| @@ -26,6 +26,7 @@ import { · 25 unmodified lines | |||
| 26 | } from "@executor-js/api/server"; | 26 | } from "@executor-js/api/server"; |
| 27 | 27 | ||
| 28 | import { AutumnService } from "../extensions/billing/service"; | 28 | import { AutumnService } from "../extensions/billing/service"; |
| 29 | import { hasPaidOrganizationSubscription } from "../extensions/billing/plans"; | ||
| 29 | import type { DbService } from "../db/db"; | 30 | import type { DbService } from "../db/db"; |
| 30 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; | 31 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; |
| 31 | import { makeExecutionLimitGate } from "./execution-gate"; | 32 | import { makeExecutionLimitGate } from "./execution-gate"; |
| @@ -34,7 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 2 unmodified lines | |||
| 34 | 35 | ||
| 35 | // Usage-metering decorator bound to the billing service, plus the two | 36 | // Usage-metering decorator bound to the billing service, plus the two |
| 36 | // pre-execution guards this layer owns, ordered cheapest first: | 37 | // pre-execution guards this layer owns, ordered cheapest first: |
| 37 | // 1. rate-limit backstop (counter DO, independent of billing) | 38 | // 1. rate-limit backstop (counter DO; free-tier abuse only — paid orgs are |
| 39 | // exempt via the subscription lookup below) | ||
| 38 | // 2. execution balance gate (Autumn check, cached 60s, fails open) | 40 | // 2. execution balance gate (Autumn check, cached 60s, fails open) |
| 39 | // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing | 41 | // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing |
| 40 | // call can't stall a user-facing execution. | 42 | // call can't stall a user-facing execution. |
| @@ -48,7 +50,18 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A · 7 unmodified lines | |||
| 48 | const balanceGate = makeExecutionLimitGate((organizationId) => | 50 | const balanceGate = makeExecutionLimitGate((organizationId) => |
| 49 | autumn.checkExecutionBalance(organizationId), | 51 | autumn.checkExecutionBalance(organizationId), |
| 50 | ); | 52 | ); |
| 51 | const rateLimiter = makeCloudExecutionRateLimiter(); | 53 | // The limiter's paid-org exemption. This is the billing coupling the |
| 54 | // limiter module deliberately avoids owning, and it reads the same | ||
| 55 | // `PAID_AUTUMN_PLAN_IDS` config as the org-creation and seat gates so | ||
| 56 | // "paid" means one thing across the app. The limiter calls this only | ||
| 57 | // for orgs already over the cap and caches the answer, so the extra | ||
| 58 | // Autumn round trip stays off the hot path. | ||
| 59 | const rateLimiter = makeCloudExecutionRateLimiter((organizationId) => | ||
| 60 | Effect.map( | ||
| 61 | autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })), | ||
| 62 | (customer) => hasPaidOrganizationSubscription(customer.subscriptions), | ||
| 63 | ), | ||
| 64 | ); | ||
| 52 | return { | 65 | return { |
| 53 | decorate: (engine, identity: EngineStackIdentity) => | 66 | decorate: (engine, identity: EngineStackIdentity) => |
| 54 | rateLimiter.decorate( | 67 | rateLimiter.decorate( |
| @@ -42,9 +42,17 @@ import { · 41 unmodified lines | |||
| 42 | PluginsProvider, | 42 | PluginsProvider, |
| 43 | collectTables, | 43 | collectTables, |
| 44 | } from "@executor-js/api/server"; | 44 | } from "@executor-js/api/server"; |
| 45 | import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | ||
| 46 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | ||
| 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; | 47 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 48 | import { | ||
| 49 | IntegrationSlug, | ||
| 50 | type AnyPlugin, | ||
| 51 | type FirstPartyOAuthClientConfig, | ||
| 52 | } from "@executor-js/sdk"; | ||
| 46 | 53 | ||
| 47 | import executorConfig from "../../executor.config"; | 54 | import executorConfig from "../../executor.config"; |
| 55 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; | ||
| 48 | import { DbService } from "../db/db"; | 56 | import { DbService } from "../db/db"; |
| 49 | import { cloudDbProviderLayer } from "../db/fuma"; | 57 | import { cloudDbProviderLayer } from "../db/fuma"; |
| 50 | 58 | ||
| @@ -54,11 +62,20 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 3 unmodified lines | |||
| 54 | // DbProvider rebuilds the fuma client over the same schema. | 62 | // DbProvider rebuilds the fuma client over the same schema. |
| 55 | export const CloudDbProvider = cloudDbProviderLayer(collectTables()); | 63 | export const CloudDbProvider = cloudDbProviderLayer(collectTables()); |
| 56 | 64 | ||
| 65 | const cloudPluginFactory = executorConfig.plugins as (deps: { | ||
| 66 | readonly workosCredentials: { | ||
| 67 | readonly apiKey: string; | ||
| 68 | readonly clientId: string; | ||
| 69 | readonly apiUrl?: string; | ||
| 70 | }; | ||
| 71 | readonly activeToolkitSlug?: string; | ||
| 72 | }) => readonly AnyPlugin[]; | ||
| 73 | |||
| 57 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault | 74 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault |
| 58 | // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. | 75 | // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. |
| 59 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ | 76 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ |
| 60 | plugins: (context) => | 77 | plugins: (context) => |
| 61 | executorConfig.plugins({ | 78 | cloudPluginFactory({ |
| 62 | workosCredentials: { | 79 | workosCredentials: { |
| 63 | apiKey: env.WORKOS_API_KEY, | 80 | apiKey: env.WORKOS_API_KEY, |
| 64 | clientId: env.WORKOS_CLIENT_ID, | 81 | clientId: env.WORKOS_CLIENT_ID, |
| @@ -66,7 +83,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 1 unmodified line | |||
| 66 | }, | 83 | }, |
| 67 | activeToolkitSlug: | 84 | activeToolkitSlug: |
| 68 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, | 85 | context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, |
| 69 | allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", | ||
| 70 | }), | 86 | }), |
| 71 | }); | 87 | }); |
| 72 | 88 | ||
| @@ -79,6 +95,98 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 6 unmodified lines | |||
| 79 | */ | 95 | */ |
| 80 | export const CLOUD_MOUNT_PREFIX = "/api" as const; | 96 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 81 | 97 | ||
| 98 | // Consumer Google launch boundary. Keep this list aligned with the scopes | ||
| 99 | // submitted for the Executor-owned production app: ordinary Workspace services | ||
| 100 | // plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, | ||
| 101 | // BigQuery, and Cloud Resource Manager have materially different audiences or | ||
| 102 | // provider requirements and remain BYO OAuth. The same scope source builds each | ||
| 103 | // catalog auth template, preventing picker/start drift. | ||
| 104 | const GOOGLE_FIRST_PARTY_PRESET_IDS = [ | ||
| 105 | "google-calendar", | ||
| 106 | "google-meet", | ||
| 107 | "google-gmail", | ||
| 108 | "google-sheets", | ||
| 109 | "google-drive", | ||
| 110 | "google-docs", | ||
| 111 | "google-slides", | ||
| 112 | "google-forms", | ||
| 113 | "google-tasks", | ||
| 114 | "google-people", | ||
| 115 | "google-photos-library", | ||
| 116 | "google-photos-picker", | ||
| 117 | "google-search-console", | ||
| 118 | ] as const; | ||
| 119 | |||
| 120 | const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ | ||
| 121 | ...new Set([ | ||
| 122 | ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), | ||
| 123 | // Connections created before the full-Gmail review retain this declared | ||
| 124 | // scope on reconnect. New Gmail presets request `mail.google.com`. | ||
| 125 | "https://www.googleapis.com/auth/gmail.modify", | ||
| 126 | ]), | ||
| 127 | ]; | ||
| 128 | |||
| 129 | // Executor-owned provider apps, enabled per provider by setting BOTH env vars | ||
| 130 | // (id + secret). Each provider-side registration must list | ||
| 131 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | ||
| 132 | // travels inside OAuth `state`, so the single static callback serves every org. | ||
| 133 | // | ||
| 134 | // The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / | ||
| 135 | // `_TOKEN_URL` overrides exist so tests and dev instances can point the app at | ||
| 136 | // an emulated provider (`@executor-js/emulate`) and run the complete flow. | ||
| 137 | // Production leaves them unset. | ||
| 138 | export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ | ||
| 139 | ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET | ||
| 140 | ? [ | ||
| 141 | { | ||
| 142 | name: "github", | ||
| 143 | authorizationUrl: | ||
| 144 | env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", | ||
| 145 | tokenUrl: | ||
| 146 | env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", | ||
| 147 | clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, | ||
| 148 | clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, | ||
| 149 | integrations: [IntegrationSlug.make("github_rest")], | ||
| 150 | // GitHub App user access tokens do not use classic OAuth scopes; | ||
| 151 | // their capabilities come from the app's registered permissions. | ||
| 152 | authorizationScopes: [], | ||
| 153 | }, | ||
| 154 | ] | ||
| 155 | : []), | ||
| 156 | ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET | ||
| 157 | ? [ | ||
| 158 | { | ||
| 159 | name: "google", | ||
| 160 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 161 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 162 | clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, | ||
| 163 | clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, | ||
| 164 | allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, | ||
| 165 | // Withdrawn from the connect picker: no new connection is offered the | ||
| 166 | // Executor-owned Google app. The entry stays declared on purpose — | ||
| 167 | // every connection already minted against it keeps refreshing and | ||
| 168 | // reconnecting through it. Deleting this block, or unsetting the env | ||
| 169 | // vars, would strand those connections instead. | ||
| 170 | unlisted: true, | ||
| 171 | }, | ||
| 172 | ] | ||
| 173 | : []), | ||
| 174 | ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET | ||
| 175 | ? [ | ||
| 176 | { | ||
| 177 | name: "slack", | ||
| 178 | authorizationUrl: "https://slack.com/oauth/v2_user/authorize", | ||
| 179 | tokenUrl: "https://slack.com/api/oauth.v2.user.access", | ||
| 180 | resource: "https://mcp.slack.com", | ||
| 181 | clientId: env.FIRST_PARTY_SLACK_CLIENT_ID, | ||
| 182 | clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET, | ||
| 183 | integrations: [IntegrationSlug.make("slack")], | ||
| 184 | allowedScopes: slackMcpUserScopes, | ||
| 185 | }, | ||
| 186 | ] | ||
| 187 | : []), | ||
| 188 | ]; | ||
| 189 | |||
| 82 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ | 190 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 83 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: | 191 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 84 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); | 192 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); |
| @@ -90,6 +198,12 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines | |||
| 90 | // WorkOS Vault is cloud's credential storage implementation detail, not a | 198 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 91 | // user-selectable provider surface. | 199 | // user-selectable provider surface. |
| 92 | exposeCredentialProviders: false, | 200 | exposeCredentialProviders: false, |
| 201 | firstPartyOAuthClients: cloudFirstPartyOAuthClients(), | ||
| 202 | // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the | ||
| 203 | // one host with a flag service, so cloud is the one host that installs a | ||
| 204 | // gate; everywhere else the seam stays empty and the profile is attempted as | ||
| 205 | // before. Gating happens at connect only — see the SDK contract. | ||
| 206 | enterpriseManagedRollout: cloudEnterpriseManagedRollout(), | ||
| 93 | })); | 207 | })); |
| 94 | 208 | ||
| 95 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( | 209 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| @@ -12,14 +12,20 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>( · 11 unmodified lines | |||
| 12 | engine | 12 | engine |
| 13 | .execute(code, options) | 13 | .execute(code, options) |
| 14 | .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), | 14 | .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), |
| 15 | executeWithPause: (code) => | 15 | // `options` MUST be forwarded: it carries `autoApprove`, and dropping it |
| 16 | // silently turned every auto-approved execution back into a paused one. | ||
| 17 | executeWithPause: (code, options) => | ||
| 16 | engine | 18 | engine |
| 17 | .executeWithPause(code) | 19 | .executeWithPause(code, options) |
| 18 | .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), | 20 | .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), |
| 19 | // resume doesn't count as usage | 21 | // resume doesn't count as usage |
| 20 | resume: (executionId, response) => engine.resume(executionId, response), | 22 | resume: (executionId, response) => engine.resume(executionId, response), |
| 23 | isExecutionSettled: engine.isExecutionSettled, | ||
| 21 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), | 24 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), |
| 22 | pausedExecutionCount: () => engine.pausedExecutionCount(), | 25 | pausedExecutionCount: () => engine.pausedExecutionCount(), |
| 23 | hasPausedExecutions: () => engine.hasPausedExecutions(), | 26 | hasPausedExecutions: () => engine.hasPausedExecutions(), |
| 24 | getDescription: engine.getDescription, | 27 | getDescription: engine.getDescription, |
| 28 | // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers, | ||
| 29 | // so the host's request-scope teardown has to reach through this decorator. | ||
| 30 | shutdown: engine.shutdown, | ||
| 25 | }); | 31 | }); |
| @@ -0,0 +1,87 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | ||
| 2 | import { beforeAll, describe, expect, it } from "@effect/vitest"; | ||
| 3 | |||
| 4 | import { cloudFirstPartyOAuthClients } from "./execution-stack"; | ||
| 5 | |||
| 6 | // The reviewed consumer scope boundary of the Executor-owned Google app. | ||
| 7 | // | ||
| 8 | // These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`, | ||
| 9 | // read off `listClients`. The app is now `unlisted`, so it has no read surface | ||
| 10 | // to introspect — the bundle is only observable on the config it is built from, | ||
| 11 | // which is here. The e2e still owns the BEHAVIOUR the boundary produces (which | ||
| 12 | // scopes an `oauth.start` requests, and that admin scopes are refused). | ||
| 13 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; | ||
| 14 | |||
| 15 | describe("cloud first-party oauth clients", () => { | ||
| 16 | beforeAll(() => { | ||
| 17 | env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; | ||
| 18 | env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; | ||
| 19 | }); | ||
| 20 | |||
| 21 | const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); | ||
| 22 | |||
| 23 | it("declares the Google app but withholds it from every listing", () => { | ||
| 24 | const client = google(); | ||
| 25 | expect(client, "the env-declared first-party Google app is configured").toBeDefined(); | ||
| 26 | // The entry MUST stay declared: `loadClient` resolves it by slug for every | ||
| 27 | // existing connection's refresh and reconnect. `unlisted` is what stops it | ||
| 28 | // being offered for new connections. | ||
| 29 | expect(client?.unlisted).toBe(true); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("covers the reviewed consumer bundle", () => { | ||
| 33 | const allowed = google()?.allowedScopes; | ||
| 34 | expect(allowed).toBeDefined(); | ||
| 35 | for (const scope of [ | ||
| 36 | "calendar", | ||
| 37 | "meetings.space.readonly", | ||
| 38 | "spreadsheets", | ||
| 39 | "drive.file", | ||
| 40 | "drive", | ||
| 41 | "documents", | ||
| 42 | "presentations", | ||
| 43 | "forms.body", | ||
| 44 | "forms.responses.readonly", | ||
| 45 | "tasks", | ||
| 46 | "contacts", | ||
| 47 | "contacts.other.readonly", | ||
| 48 | "directory.readonly", | ||
| 49 | "user.addresses.read", | ||
| 50 | "user.birthday.read", | ||
| 51 | "user.emails.read", | ||
| 52 | "user.gender.read", | ||
| 53 | "user.organization.read", | ||
| 54 | "user.phonenumbers.read", | ||
| 55 | "photoslibrary.appendonly", | ||
| 56 | "photoslibrary.edit.appcreateddata", | ||
| 57 | "photospicker.mediaitems.readonly", | ||
| 58 | "webmasters", | ||
| 59 | "gmail.settings.basic", | ||
| 60 | ]) { | ||
| 61 | expect(allowed).toContain(GOOGLE_SCOPE(scope)); | ||
| 62 | } | ||
| 63 | // `gmail.modify` stays in the host-enforced allowlist on purpose: a | ||
| 64 | // connection created before the full-Gmail review still declares it, and | ||
| 65 | // `resolveFirstPartyScopes` filters discovered scopes through this list, so | ||
22 more lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 149 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 213 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 237 lines aren’t shown here.
This diff is large: its 168 lines aren’t shown here.
This diff is large: its 210 lines aren’t shown here.
This diff is large: its 161 lines aren’t shown here.
This diff is large: its 95 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 253 lines aren’t shown here.
This diff is large: its 329 lines aren’t shown here.
This diff is large: its 91 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
Binary file not shown.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 310 lines aren’t shown here.
This diff is large: its 212 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 450 lines aren’t shown here.
This diff is large: its 82 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 48 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
Binary file not shown.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 144 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 100 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 93 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 164 lines aren’t shown here.
This diff is large: its 71 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 116 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 208 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 103 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 188 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 229 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 61 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 296 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 90 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 185 lines aren’t shown here.
This diff is large: its 226 lines aren’t shown here.
This diff is large: its 235 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 127 lines aren’t shown here.
This diff is large: its 84 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 59 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
File renamed without changes.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 211 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 83 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 275 lines aren’t shown here.
This diff is large: its 74 lines aren’t shown here.
This diff is large: its 112 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 928 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 43 lines aren’t shown here.
This diff is large: its 69 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 100 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 113 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 127 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 159 lines aren’t shown here.
This diff is large: its 555 lines aren’t shown here.
This diff is large: its 219 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
Binary file not shown.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 1477 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 323 lines aren’t shown here.
This diff is large: its 174 lines aren’t shown here.
This diff is large: its 284 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 391 lines aren’t shown here.
This diff is large: its 323 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 282 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 233 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 571 lines aren’t shown here.
This diff is large: its 133 lines aren’t shown here.
File renamed without changes.
File renamed without changes.
This diff is large: its 167 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 133 lines aren’t shown here.
This diff is large: its 366 lines aren’t shown here.
This diff is large: its 201 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 192 lines aren’t shown here.
This diff is large: its 253 lines aren’t shown here.
This diff is large: its 107 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 131 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 281 lines aren’t shown here.
This diff is large: its 206 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 161 lines aren’t shown here.
This diff is large: its 188 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 129 lines aren’t shown here.
This diff is large: its 253 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 46 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
Binary file not shown.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 229 lines aren’t shown here.
This diff is large: its 332 lines aren’t shown here.
This diff is large: its 283 lines aren’t shown here.
This diff is large: its 855 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 170 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 141 lines aren’t shown here.
This diff is large: its 532 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 383 lines aren’t shown here.
This diff is large: its 426 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 147 lines aren’t shown here.
This diff is large: its 107 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 96 lines aren’t shown here.
This diff is large: its 199 lines aren’t shown here.
This diff is large: its 165 lines aren’t shown here.
This diff is large: its 168 lines aren’t shown here.
This diff is large: its 147 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 126 lines aren’t shown here.
This diff is large: its 327 lines aren’t shown here.
This diff is large: its 504 lines aren’t shown here.
This diff is large: its 292 lines aren’t shown here.
This diff is large: its 337 lines aren’t shown here.
This diff is large: its 234 lines aren’t shown here.
This diff is large: its 43 lines aren’t shown here.
File renamed without changes.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 251 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 163 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 103 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 166 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 179 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 74 lines aren’t shown here.
This diff is large: its 95 lines aren’t shown here.
This diff is large: its 127 lines aren’t shown here.
This diff is large: its 232 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 332 lines aren’t shown here.
This diff is large: its 168 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 82 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 444 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 306 lines aren’t shown here.
This diff is large: its 134 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 276 lines aren’t shown here.
This diff is large: its 115 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 188 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 72 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 136 lines aren’t shown here.
This diff is large: its 138 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 72 lines aren’t shown here.
This diff is large: its 202 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 118 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 116 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 133 lines aren’t shown here.
This diff is large: its 91 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 185 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 83 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 234 lines aren’t shown here.
This diff is large: its 241 lines aren’t shown here.
This diff is large: its 160 lines aren’t shown here.
This diff is large: its 64 lines aren’t shown here.
This diff is large: its 72 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 134 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 1195 lines aren’t shown here.
This diff is large: its 363 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 388 lines aren’t shown here.
This diff is large: its 82 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 166 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 120 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 234 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 131 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 240 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 91 lines aren’t shown here.
This diff is large: its 167 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 222 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 85 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 120 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 262 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 275 lines aren’t shown here.
This diff is large: its 39 lines aren’t shown here.
This diff is large: its 617 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 611 lines aren’t shown here.
This diff is large: its 276 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 93 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 305 lines aren’t shown here.
This diff is large: its 575 lines aren’t shown here.
This diff is large: its 133 lines aren’t shown here.
This diff is large: its 212 lines aren’t shown here.
This diff is large: its 354 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 90 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 268 lines aren’t shown here.
This diff is large: its 203 lines aren’t shown here.
This diff is large: its 210 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 306 lines aren’t shown here.
This diff is large: its 2569 lines aren’t shown here.
This diff is large: its 266 lines aren’t shown here.
This diff is large: its 226 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 217 lines aren’t shown here.
This diff is large: its 378 lines aren’t shown here.
This diff is large: its 275 lines aren’t shown here.
This diff is large: its 59 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 255 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 95 lines aren’t shown here.
This diff is large: its 337 lines aren’t shown here.
This diff is large: its 468 lines aren’t shown here.
This diff is large: its 503 lines aren’t shown here.
This diff is large: its 477 lines aren’t shown here.
This diff is large: its 472 lines aren’t shown here.
This diff is large: its 1026 lines aren’t shown here.
This diff is large: its 383 lines aren’t shown here.
This diff is large: its 902 lines aren’t shown here.
This diff is large: its 232 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 130 lines aren’t shown here.
This diff is large: its 1207 lines aren’t shown here.
This diff is large: its 319 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 902 lines aren’t shown here.
This diff is large: its 258 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 213 lines aren’t shown here.
This diff is large: its 48 lines aren’t shown here.
This diff is large: its 270 lines aren’t shown here.
This diff is large: its 170 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 220 lines aren’t shown here.
This diff is large: its 91 lines aren’t shown here.
This diff is large: its 87 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 73 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 419 lines aren’t shown here.
Binary file not shown.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 252 lines aren’t shown here.
This diff is large: its 467 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 150 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 637 lines aren’t shown here.
This diff is large: its 709 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 83 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 276 lines aren’t shown here.
This diff is large: its 238 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 103 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 46 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 433 lines aren’t shown here.
This diff is large: its 218 lines aren’t shown here.
This diff is large: its 54 lines aren’t shown here.
This diff is large: its 191 lines aren’t shown here.
This diff is large: its 284 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
Binary file not shown.
Binary file not shown.
This diff is large: its 54 lines aren’t shown here.
This diff is large: its 159 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 433 lines aren’t shown here.
This diff is large: its 2650 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 182 lines aren’t shown here.
This diff is large: its 61 lines aren’t shown here.
This diff is large: its 1099 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 275 lines aren’t shown here.
This diff is large: its 169 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 389 lines aren’t shown here.
This diff is large: its 190 lines aren’t shown here.
This diff is large: its 197 lines aren’t shown here.
This diff is large: its 318 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 262 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 412 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 76 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 399 lines aren’t shown here.
This diff is large: its 420 lines aren’t shown here.
This diff is large: its 2558 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 490 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 160 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 223 lines aren’t shown here.
This diff is large: its 120 lines aren’t shown here.
This diff is large: its 327 lines aren’t shown here.
This diff is large: its 1657 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 145 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 263 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 210 lines aren’t shown here.
This diff is large: its 100 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 252 lines aren’t shown here.
This diff is large: its 741 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 73 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 209 lines aren’t shown here.
This diff is large: its 124 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 116 lines aren’t shown here.
This diff is large: its 209 lines aren’t shown here.
This diff is large: its 324 lines aren’t shown here.
This diff is large: its 144 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 179 lines aren’t shown here.
This diff is large: its 149 lines aren’t shown here.
This diff is large: its 364 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 800 lines aren’t shown here.
This diff is large: its 470 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 146 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 132 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 380 lines aren’t shown here.
This diff is large: its 499 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 166 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 396 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 43 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 155 lines aren’t shown here.
This diff is large: its 198 lines aren’t shown here.
This diff is large: its 148 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 411 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 67 lines aren’t shown here.
This diff is large: its 202 lines aren’t shown here.
This diff is large: its 71 lines aren’t shown here.
This diff is large: its 112 lines aren’t shown here.
This diff is large: its 76 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 204 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 433 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 183 lines aren’t shown here.
This diff is large: its 172 lines aren’t shown here.
This diff is large: its 154 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 113 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 172 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 142 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 164 lines aren’t shown here.
This diff is large: its 145 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 119 lines aren’t shown here.
This diff is large: its 287 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is larger than a page shows: the files after these aren’t listed. Clone the repository to see all of it.