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 | 18 | "@executor-js/plugin-openapi", |
| 19 | 19 | "@executor-js/plugin-example", |
| 20 | 20 | "@executor-js/plugin-desktop-settings", |
| 21 | − "@executor-js/desktop" | |
| 21 | + "@executor-js/desktop", | |
| 22 | + "@executor-js/local" | |
| 22 | 23 | ] |
| 23 | 24 | ], |
| 24 | 25 | "linked": [], |
| @@ -29,7 +30,6 @@ · 4 unmodified lines | ||
| 29 | 30 | "onlyUpdatePeerDependentsWhenOutOfRange": true |
| 30 | 31 | }, |
| 31 | 32 | "ignore": [ |
| 32 | − "@executor-js/local", | |
| 33 | 33 | "@executor-js/host-mcp", |
| 34 | 34 | "@executor-js/ir", |
| 35 | 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 | 32 | `servers`) when a proxy fronts the emulator — the bind stays on `port`. |
| 33 | 33 | |
| 34 | 34 | **Attach to a running one** (another process, or hosted on Cloudflare with |
| 35 | −Durable Object state at `https://<service>.emulators.dev` / | |
| 36 | −`https://<service>.<instance>.emulators.dev` — catalog at | |
| 37 | −`GET https://emulators.dev/_emulate/services`): | |
| 35 | +Durable Object state at `https://<service>.<instance>.emulators.dev` — | |
| 36 | +catalog at `GET https://emulators.dev/_emulate/services`). Service hosts | |
| 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 | 41 | ```ts |
| 40 | 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 | 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 | |
| 46 | −service host. | |
| 49 | +In e2e scenarios use `createEmulatorInstance` from | |
| 50 | +`e2e/src/emulator-instance.ts`, which wraps this. | |
| 47 | 51 | |
| 48 | 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 | 117 | publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read |
| 114 | 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 | |
| 117 | −hosts are shared infrastructure; a control-plane regression there has failed | |
| 118 | −unrelated PRs' suites before. When a scenario needs isolation or a behavior | |
| 119 | −that isn't deployed yet, pin to a published package version or mint a private | |
| 120 | −per-run instance (`POST /_emulate/instances`) instead of mutating the shared | |
| 121 | −service host. | |
| 120 | +**A hot deploy can redden other people's e2e.** The `emulate-hosts` worker | |
| 121 | +behind `*.emulators.dev` is shared infrastructure; a control-plane regression | |
| 122 | +there has failed unrelated PRs' suites before. Scenarios always run on private | |
| 123 | +per-run instances (`POST /_emulate/instances`); when a scenario needs behavior | |
| 124 | +that isn't deployed yet, pin to a published package version. | |
| 122 | 125 | |
| 123 | 126 | ## Gotchas |
| 124 | 127 | |
| @@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines | ||
| 29 | 29 | |
| 30 | 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 | 46 | - `executor.tool.execute` — `mcp.tool.name` (full address), and since |
| 33 | 47 | PR #992: `executor.tool.outcome` (`ok`/`fail`), |
| 34 | 48 | `executor.tool.error_code`, `executor.tool.error_status`, |
| @@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines | ||
| 39 | 53 | `base_url`, and since PR #992 `http.status_code`. |
| 40 | 54 | - `mcp.request` (outer) — `mcp.auth.organization_id`, |
| 41 | 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 | 61 | **Recipe — error signatures by class (the daily-digest query):** |
| 45 | 62 | |
| @@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines | ||
| 10 | 10 | group: ci-${{ github.ref }} |
| 11 | 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 | 25 | jobs: |
| 14 | 26 | changes: |
| 15 | 27 | name: Changed paths |
| @@ -18,6 +30,7 @@ jobs: · 2 unmodified lines | ||
| 18 | 30 | outputs: |
| 19 | 31 | desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} |
| 20 | 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 | 34 | steps: |
| 22 | 35 | - uses: actions/checkout@v4 |
| 23 | 36 | |
| @@ -41,6 +54,14 @@ jobs: · 17 unmodified lines | ||
| 41 | 54 | - "packages/kernel/runtime-quickjs/**" |
| 42 | 55 | - "packages/plugins/**" |
| 43 | 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 | 65 | selfhost_docker_smoke: |
| 45 | 66 | - ".github/workflows/**" |
| 46 | 67 | - ".dockerignore" |
| @@ -136,17 +157,26 @@ jobs: · 89 unmodified lines | ||
| 136 | 157 | |
| 137 | 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 | 166 | test: |
| 140 | 167 | name: Test |
| 141 | − runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 168 | + runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| 142 | 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 | 173 | env: |
| 145 | 174 | TURBO_API: ${{ vars.TURBO_API }} |
| 146 | 175 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} |
| 147 | 176 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} |
| 148 | 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 | 180 | steps: |
| 151 | 181 | - uses: actions/checkout@v4 |
| 152 | 182 | |
| @@ -178,6 +208,8 @@ jobs: · 25 unmodified lines | ||
| 178 | 208 | # no signal. This is just a few file reads plus greps, so ~zero cost. |
| 179 | 209 | - run: bun run check:patches |
| 180 | 210 | |
| 211 | + - run: bun run --cwd e2e test:ci-shard | |
| 212 | + | |
| 181 | 213 | - run: bun run test |
| 182 | 214 | |
| 183 | 215 | e2e: |
| @@ -186,14 +218,41 @@ jobs: · 2 unmodified lines | ||
| 186 | 218 | fail-fast: false |
| 187 | 219 | matrix: |
| 188 | 220 | include: |
| 189 | − # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, | |
| 190 | − # four fatter shards keep the longest shard below selfhost while saving | |
| 191 | − # four runner boots and four warm cache restores. | |
| 192 | − - { target: cloud, shard: 1/4, shard-name: 1of4 } | |
| 193 | − - { target: cloud, shard: 2/4, shard-name: 2of4 } | |
| 194 | − - { target: cloud, shard: 3/4, shard-name: 3of4 } | |
| 195 | − - { target: cloud, shard: 4/4, shard-name: 4of4 } | |
| 196 | − - target: selfhost | |
| 221 | + # The planner assigns every file exactly once using recorded slow-file | |
| 222 | + # durations plus a conservative weight for new tests. The cloud DB's | |
| 223 | + # connection teardown and concurrent socket protocol have dedicated | |
| 224 | + # regression tests; these shards balance wall clock, not hide retries. | |
| 225 | + - { target: cloud, shard-index: 1, shard-name: 1of16 } | |
| 226 | + - { target: cloud, shard-index: 2, shard-name: 2of16 } | |
| 227 | + - { target: cloud, shard-index: 3, shard-name: 3of16 } | |
| 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 | 256 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 198 | 257 | timeout-minutes: 30 |
| 199 | 258 | steps: |
| @@ -229,26 +288,31 @@ jobs: · 29 unmodified lines | ||
| 229 | 288 | |
| 230 | 289 | # Install from e2e so bunx resolves ITS pinned playwright (the version |
| 231 | 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 | 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 | 296 | working-directory: e2e |
| 235 | 297 | |
| 236 | − # The globalsetup boots the target's own dev server (ports are claimed | |
| 237 | − # per checkout, so this is hermetic) and tears it down after the run. | |
| 238 | − # --retry=2: browser scenarios can still hit isolated waitFor timeouts | |
| 239 | − # (single-test waitFor timeouts, not systemic failures); a retry on the | |
| 240 | − # same booted stack clears them. | |
| 241 | − - name: Run cloud scenarios | |
| 298 | + # Each target either boots its own shared dev server or lets each file own | |
| 299 | + # its server. Ports and data paths are hermetic in both cases. | |
| 300 | + # Do not retry scenarios: retries hide flakes and multiply slow timeout | |
| 301 | + # failures. The fixtures and process lifecycle are deterministic enough | |
| 302 | + # that the first result is the result. | |
| 303 | + - name: Run cloud shard | |
| 242 | 304 | if: matrix.target == 'cloud' |
| 243 | 305 | env: |
| 244 | 306 | MCP_SESSION_TIMEOUT_MS: "3000" |
| 245 | − MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" | |
| 246 | − run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | |
| 307 | + # Still 18x shorter than production, but long enough for a cold Vite | |
| 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 | 311 | working-directory: e2e |
| 248 | 312 | |
| 249 | − - name: Run selfhost scenarios | |
| 250 | − if: matrix.target == 'selfhost' | |
| 251 | − run: bunx vitest run --project selfhost --retry=2 | |
| 313 | + - name: Run scenarios | |
| 314 | + if: matrix.target != 'cloud' | |
| 315 | + run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }} | |
| 252 | 316 | working-directory: e2e |
| 253 | 317 | |
| 254 | 318 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in |
| @@ -261,13 +325,23 @@ jobs: · 6 unmodified lines | ||
| 261 | 325 | path: e2e/runs/ |
| 262 | 326 | retention-days: 7 |
| 263 | 327 | |
| 264 | − e2e-local: | |
| 265 | − name: E2E (stdio MCP) | |
| 266 | − # Skipped on pull_request: the local scenario boots a real `executor web` | |
| 267 | − # plus a browser and is currently flaky on PRs. Still runs on push to main. | |
| 268 | − if: github.event_name != 'pull_request' | |
| 328 | + cloud-closure: | |
| 329 | + name: Cloud evaluated closure | |
| 330 | + needs: changes | |
| 331 | + if: needs.changes.outputs.cloud_closure == 'true' | |
| 269 | 332 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 270 | 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 | 345 | steps: |
| 272 | 346 | - uses: actions/checkout@v4 |
| 273 | 347 | |
| @@ -283,41 +357,17 @@ jobs: · 9 unmodified lines | ||
| 283 | 357 | restore-keys: | |
| 284 | 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 | 360 | - uses: actions/setup-node@v4 |
| 289 | 361 | with: |
| 290 | 362 | node-version: 24 |
| 291 | 363 | |
| 292 | 364 | - run: bun install --frozen-lockfile |
| 293 | 365 | |
| 294 | − - name: Cache Playwright browsers | |
| 295 | − uses: actions/cache@v4 | |
| 296 | − with: | |
| 297 | − path: ~/.cache/ms-playwright | |
| 298 | − key: ${{ runner.os }}-playwright-1.60.0 | |
| 299 | − restore-keys: | | |
| 300 | − ${{ runner.os }}-playwright- | |
| 366 | + - run: bun run build | |
| 367 | + working-directory: apps/cloud | |
| 301 | 368 | |
| 302 | − # `chromium` and the new `chromium-headless-shell` ship as separate | |
| 303 | − # downloads; the browser-driven scenarios launch the headless shell. | |
| 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 | |
| 369 | + - run: node scripts/start-closure.mjs | |
| 370 | + working-directory: apps/cloud | |
| 321 | 371 | |
| 322 | 372 | desktop-smoke: |
| 323 | 373 | name: Desktop smoke build |
| @@ -369,7 +419,13 @@ jobs: · 45 unmodified lines | ||
| 369 | 419 | - name: Run sidecar outside the workspace |
| 370 | 420 | working-directory: apps/desktop |
| 371 | 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 | 429 | -v "$PWD/resources/executor:/opt/executor:ro" \ |
| 374 | 430 | debian:bookworm-slim /opt/executor/executor daemon run --foreground \ |
| 375 | 431 | --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke |
| @@ -414,4 +470,35 @@ jobs: · 38 unmodified lines | ||
| 414 | 470 | context: . |
| 415 | 471 | file: apps/host-selfhost/Dockerfile |
| 416 | 472 | push: false |
| 473 | + load: true | |
| 417 | 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 | 9 | group: deploy-production |
| 10 | 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 | 22 | jobs: |
| 13 | 23 | migrate: |
| 14 | 24 | name: Migrate database |
| @@ -77,12 +87,30 @@ jobs: · 62 unmodified lines | ||
| 77 | 87 | VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} |
| 78 | 88 | |
| 79 | 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 | 91 | working-directory: apps/cloud |
| 82 | 92 | env: |
| 83 | 93 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 84 | 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 | 114 | deploy-marketing: |
| 87 | 115 | name: Deploy marketing |
| 88 | 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 | 10 | group: pkg-pr-new-${{ github.event.pull_request.number }} |
| 11 | 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 | 23 | jobs: |
| 14 | 24 | # Per-platform matrix: build the executor binary, tar it, upload to R2. |
| 15 | 25 | # The wrapper npm package is built later by the `publish` job which just |
| @@ -16,6 +16,16 @@ permissions: · 15 unmodified lines | ||
| 16 | 16 | contents: read |
| 17 | 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 | 29 | jobs: |
| 20 | 30 | sweep: |
| 21 | 31 | name: Destroy previews for closed PRs |
| @@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines | ||
| 23 | 23 | group: preview-${{ github.event.pull_request.number }} |
| 24 | 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 | 36 | jobs: |
| 27 | 37 | deploy: |
| 28 | 38 | name: Deploy preview |
| @@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines | ||
| 21 | 21 | group: publish-desktop-${{ github.ref }} |
| 22 | 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 | 36 | jobs: |
| 25 | 37 | build: |
| 26 | 38 | permissions: |
| @@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines | ||
| 21 | 21 | group: publish-executor-package-${{ github.ref }} |
| 22 | 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 | 34 | jobs: |
| 25 | 35 | publish: |
| 26 | 36 | runs-on: ubuntu-latest |
| @@ -62,7 +72,9 @@ jobs: · 35 unmodified lines | ||
| 62 | 72 | |
| 63 | 73 | - name: Update npm for trusted publishing |
| 64 | 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 | 78 | npm --version |
| 67 | 79 | |
| 68 | 80 | - name: Install dependencies |
| @@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines | ||
| 16 | 16 | group: publish-selfhost-docker-${{ inputs.tag }} |
| 17 | 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 | 31 | jobs: |
| 20 | 32 | metadata: |
| 21 | 33 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -163,16 +175,124 @@ jobs: · 141 unmodified lines | ||
| 163 | 175 | if-no-files-found: error |
| 164 | 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 | 288 | merge: |
| 167 | 289 | needs: |
| 168 | 290 | - metadata |
| 169 | 291 | - build |
| 292 | + - e2e | |
| 170 | 293 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 171 | 294 | permissions: |
| 172 | 295 | packages: write |
| 173 | − env: | |
| 174 | − HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | |
| 175 | − | |
| 176 | 296 | steps: |
| 177 | 297 | - name: Download image digests |
| 178 | 298 | uses: actions/download-artifact@v4 |
| @@ -219,10 +339,28 @@ jobs: · 40 unmodified lines | ||
| 219 | 339 | |
| 220 | 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 | 356 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) |
| 223 | 357 | if: env.HAS_LEGACY_TOKEN != 'true' |
| 224 | 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 | 364 | - name: Log in to legacy GHCR namespace |
| 227 | 365 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 228 | 366 | uses: docker/login-action@v3 |
| @@ -231,7 +369,7 @@ jobs: · 2 unmodified lines | ||
| 231 | 369 | username: rhyssullivan |
| 232 | 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 | 373 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 236 | 374 | shell: bash |
| 237 | 375 | env: |
| @@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines | ||
| 13 | 13 | group: release-${{ github.ref }} |
| 14 | 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 | 28 | jobs: |
| 17 | 29 | release: |
| 18 | 30 | runs-on: ubuntu-latest |
| @@ -42,7 +54,9 @@ jobs: · 23 unmodified lines | ||
| 42 | 54 | |
| 43 | 55 | - name: Update npm for trusted publishing |
| 44 | 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 | 60 | npm --version |
| 47 | 61 | |
| 48 | 62 | - name: Install dependencies |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | "*.tsbuildinfo", |
| 14 | 14 | "executor-*.tgz", |
| 15 | 15 | "**/routeTree.gen.ts", |
| 16 | + "**/smoke-harness-bundle.gen.ts", | |
| 16 | 17 | "apps/cloud/src/services/executor-schema.ts" |
| 17 | 18 | ] |
| 18 | 19 | } |
| @@ -64,6 +64,7 @@ · 63 unmodified lines | ||
| 64 | 64 | "apps/desktop/src/main.ts", |
| 65 | 65 | "scripts/**/*.{ts,js}", |
| 66 | 66 | "apps/*/scripts/**/*.{ts,js}", |
| 67 | + "packages/*/*/scripts/**/*.{ts,js}", | |
| 67 | 68 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", |
| 68 | 69 | ], |
| 69 | 70 | "rules": { |
| @@ -119,6 +120,31 @@ · 49 unmodified lines | ||
| 119 | 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 | 149 | "files": ["packages/kernel/core/src/code-recovery.ts"], |
| 124 | 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 | |
| 4 | −run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for | |
| 5 | −e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in | |
| 6 | −any fresh checkout or worktree. | |
| 3 | +Stable contracts live here. Current setup and server mechanics are in | |
| 4 | +[RUNNING.md](RUNNING.md); e2e invariants are in | |
| 5 | +[e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift. | |
| 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` | |
| 11 | − is fine (it delegates to Vitest); NEVER run `bun test`. | |
| 12 | −- Run the narrowest useful verification for a change. For broad or merge-ready | |
| 13 | − work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`. | |
| 14 | −- Run `bun run format` before opening a PR, staging only files your branch | |
| 15 | − touched (leave unrelated files `format` rewrites out of the PR). | |
| 9 | +- Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the | |
| 10 | + package script. Never use `bun test`. | |
| 11 | +- Use the narrowest meaningful verification while iterating. Merge-ready gates | |
| 12 | + are `bun run format:check`, `lint`, `typecheck`, and `test`. | |
| 13 | +- e2e is slow. Avoid running it often during development: optimize for | |
| 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 | |
| 20 | −sees, hand back: an e2e run that proves it (link the specific run with one line | |
| 21 | −on what to look at, not a wall of green), the dev server left running so they can | |
| 22 | −poke at it, and the paths worth trying by hand including ones no scenario covers | |
| 23 | −yet. If no scenario covers the change, write one, and embed its recording in the | |
| 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. | |
| 23 | +Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use | |
| 24 | +the published `@executor-js/emulate` emulators rather than stubs. They provide | |
| 25 | +wire-level state, real-shaped credentials, OpenAPI descriptions, and request | |
| 26 | +ledgers. Create per-run hosted instances through the service's | |
| 27 | +`/_emulate/instances` control route. | |
| 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 | |
| 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 | |
| 33 | +## Engineering boundaries | |
| 58 | 34 | |
| 59 | 35 | - Prefer correctness and predictable behavior over short-term convenience. |
| 60 | −- Preserve runtime behavior when changing lint, typing, or test structure. | |
| 61 | −- Keep package boundaries clear; use public package exports, not cross-package | |
| 62 | − relative imports. | |
| 63 | −- Extract shared logic only when the shared behavior is real; avoid broad generic | |
| 36 | +- Preserve runtime behavior during lint, typing, and test-structure changes. | |
| 37 | +- Use public package exports; never cross package boundaries with relative | |
| 38 | + imports. | |
| 39 | +- Extract shared logic only for genuinely shared behavior. Avoid generic | |
| 64 | 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, | |
| 69 | − policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method | |
| 70 | − vocabulary the HTTP protocol plugins compose (core never imports it, keeping it | |
| 71 | − carrier-agnostic). | |
| 46 | +- `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets, | |
| 47 | + policies, and fixtures. Shared HTTP auth vocabulary lives in | |
| 48 | + `@executor-js/sdk/http-auth`; core remains carrier-agnostic. | |
| 72 | 49 | - `packages/core/storage-*`: storage adapters and test support. |
| 73 | −- `packages/plugins/*`: protocol and provider plugins; their runtime, React, API, | |
| 74 | − and testing helpers live with the owning plugin. | |
| 75 | −- `packages/react`: shared React UI and atom/client integration. | |
| 50 | +- `packages/plugins/*`: protocol/provider implementations and their runtime, | |
| 51 | + React, API, and testing helpers. | |
| 52 | +- `packages/react`: shared React UI and client/atom integration. | |
| 76 | 53 | - `packages/hosts/mcp`: MCP host surface. |
| 77 | −- `packages/kernel/*`: execution runtimes and code execution substrate. | |
| 78 | −- `apps/{local,cloud,cli,desktop}`: product entry points composing the packages. | |
| 79 | − | |
| 80 | −## Other | |
| 54 | +- `packages/kernel/*`: execution runtimes and code-execution substrate. | |
| 55 | +- `apps/{local,cloud,cli,desktop}`: product composition roots. | |
| 81 | 56 | |
| 82 | −Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env | |
| 83 | −learnings in LEARNINGS.md. | |
| 57 | +Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and | |
| 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 | 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 | 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 | 21 | auth, and policies live in one place instead of being scattered across each |
| 22 | 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 | 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 | 27 | - **One catalog, every agent.** Anything MCP-compatible connects to the same |
| 28 | 28 | set of tools. |
| 29 | 29 | - **Governed by policy.** Each tool is allowed, gated behind approval, or |
| @@ -100,12 +100,12 @@ appear. · 70 unmodified lines | ||
| 100 | 100 | |
| 101 | 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 | 104 | and Executor detects the type, indexes the tools, and handles auth. Or add one |
| 105 | 105 | from the CLI: |
| 106 | 106 | |
| 107 | 107 | ```bash |
| 108 | −executor call executor openapi addSource '{ | |
| 108 | +executor call executor openapi addIntegration '{ | |
| 109 | 109 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", |
| 110 | 110 | "namespace": "petstore", |
| 111 | 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 | 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 | 118 | ## Using tools |
| 119 | 119 | |
| @@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines | ||
| 169 | 169 | executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces |
| 170 | 170 | executor resume --execution-id <id> # resume paused execution |
| 171 | 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 | 173 | executor tools describe <path> # show tool TypeScript/JSON schema |
| 174 | 174 | ``` |
| 175 | 175 | |
| @@ -190,7 +190,7 @@ apps/ · 14 unmodified lines | ||
| 190 | 190 | packages/ |
| 191 | 191 | core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core |
| 192 | 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 | 194 | microsoft, 1password, keychain, encrypted/file secrets, ...) |
| 195 | 195 | hosts/ host adapters (MCP surface, Cloudflare) |
| 196 | 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 | 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 | 242 | ## 1.5.29 |
| 4 | 243 | |
| 5 | 244 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "executor", |
| 3 | − "version": "1.5.29", | |
| 3 | + "version": "1.6.0", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "bin": { |
| 6 | 6 | "executor": "./bin/executor.ts" |
| @@ -37,8 +37,11 @@ · 30 unmodified lines | ||
| 37 | 37 | "quickjs-emscripten": "catalog:" |
| 38 | 38 | }, |
| 39 | 39 | "devDependencies": { |
| 40 | + "@cloudflare/worker-bundler": "0.2.1", | |
| 40 | 41 | "@effect/vitest": "catalog:", |
| 42 | + "@types/node": "catalog:", | |
| 41 | 43 | "bun-types": "catalog:", |
| 44 | + "esbuild": "^0.27.3", | |
| 42 | 45 | "typescript": "catalog:", |
| 43 | 46 | "vitest": "catalog:" |
| 44 | 47 | } |
| @@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line | ||
| 2 | 2 | import { existsSync } from "node:fs"; |
| 3 | 3 | import { createRequire } from "node:module"; |
| 4 | 4 | import { resolve, join, dirname } from "node:path"; |
| 5 | −import { fileURLToPath } from "node:url"; | |
| 5 | +import { fileURLToPath, pathToFileURL } from "node:url"; | |
| 6 | 6 | import { parseArgs } from "node:util"; |
| 7 | 7 | import { $ } from "bun"; |
| 8 | +import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | |
| 8 | 9 | |
| 9 | 10 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); |
| 10 | 11 | const cliRoot = resolve(repoRoot, "apps/cli"); |
| 11 | 12 | const webRoot = resolve(repoRoot, "apps/local"); |
| 12 | 13 | const distDir = resolve(cliRoot, "dist"); |
| 13 | 14 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; |
| 15 | +const WORKERD_VERSION = "1.20260708.1"; | |
| 14 | 16 | |
| 15 | 17 | const resolveQuickJsWasmPath = (): string => { |
| 16 | 18 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); |
| @@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines | ||
| 23 | 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 | 54 | const resolveOnePasswordCoreWasmPath = (): string => { |
| 27 | 55 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); |
| 28 | 56 | const sdkPkg = req.resolve("@1password/sdk/package.json"); |
| @@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines | ||
| 33 | 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 | 141 | // Metadata |
| 38 | 142 | // --------------------------------------------------------------------------- |
| @@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines | ||
| 119 | 223 | }; |
| 120 | 224 | |
| 121 | 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 | 228 | const isCurrentPlatform = (t: Target) => |
| 124 | 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 | 355 | // Build mode |
| 216 | 356 | // --------------------------------------------------------------------------- |
| @@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines | ||
| 332 | 472 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); |
| 333 | 473 | |
| 334 | 474 | const quickJsWasmPath = resolveQuickJsWasmPath(); |
| 475 | + const mcpAppsShellPath = await ensureMcpAppsShell(); | |
| 335 | 476 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); |
| 477 | + const workerBundlerDistPath = resolveWorkerBundlerDistPath(); | |
| 478 | + const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); | |
| 336 | 479 | |
| 337 | 480 | try { |
| 338 | 481 | for (const target of targets) { |
| @@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines | ||
| 355 | 498 | // Copy QuickJS WASM next to binary — loaded at runtime by the server |
| 356 | 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 | 505 | // Copy 1Password's sdk-core WASM next to the binary. The patched |
| 359 | 506 | // sdk-core loader falls back to this sibling file when bun --compile |
| 360 | 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 | 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 | 540 | // Smoke test on current platform |
| 379 | 541 | if (isCurrentPlatform(target)) { |
| 380 | 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 | 43 | describe("browserOpenCommand", () => { |
| 6 | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | ||
| 32 | 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 | 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 | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | 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 | 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 | 158 | const post = async ( |
| 130 | 159 | url: string, |
| 131 | 160 | fields: Record<string, string | undefined>, |
| 132 | 161 | format: "form" | "json", |
| 162 | + options: DeviceLoginHttpOptions = {}, | |
| 133 | 163 | ) => |
| 134 | 164 | fetch(url, { |
| 135 | 165 | method: "POST", |
| 136 | − headers: { | |
| 137 | − "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", | |
| 138 | − accept: "application/json", | |
| 139 | − }, | |
| 166 | + headers: headersForUrl( | |
| 167 | + url, | |
| 168 | + { | |
| 169 | + "content-type": | |
| 170 | + format === "json" ? "application/json" : "application/x-www-form-urlencoded", | |
| 171 | + accept: "application/json", | |
| 172 | + }, | |
| 173 | + options, | |
| 174 | + ), | |
| 140 | 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 | 192 | let response: Response; |
| 193 | + const url = cliLoginUrl(origin); | |
| 155 | 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 | 198 | } catch (cause) { |
| 158 | 199 | throw new DeviceLoginError( |
| 159 | 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 | 230 | const response = await post( |
| 187 | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | 233 | discovery.requestFormat, |
| 234 | + options, | |
| 190 | 235 | ); |
| 191 | 236 | const body = await readJson(response); |
| 192 | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | ||
| 220 | 265 | export const pollForDeviceTokens = async ( |
| 221 | 266 | discovery: CliLoginDiscovery, |
| 222 | 267 | grant: DeviceCodeGrant, |
| 223 | − options: { readonly now?: () => number } = {}, | |
| 268 | + options: PollForDeviceTokensOptions = {}, | |
| 224 | 269 | ): Promise<DeviceTokens> => { |
| 225 | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | ||
| 240 | 285 | client_id: discovery.clientId, |
| 241 | 286 | }, |
| 242 | 287 | discovery.requestFormat, |
| 288 | + options, | |
| 243 | 289 | ); |
| 244 | 290 | const body = await readJson(response); |
| 245 | 291 | |
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | ||
| 286 | 332 | readonly tokenEndpoint: string; |
| 287 | 333 | readonly clientId: string; |
| 288 | 334 | readonly refreshToken: string; |
| 335 | + readonly headers?: Readonly<Record<string, string>>; | |
| 336 | + readonly serverOrigin?: string; | |
| 289 | 337 | }): Promise<DeviceTokens> => { |
| 290 | 338 | const response = await post( |
| 291 | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | ||
| 295 | 343 | client_id: input.clientId, |
| 296 | 344 | }, |
| 297 | 345 | "form", |
| 346 | + input, | |
| 298 | 347 | ); |
| 299 | 348 | const body = await readJson(response); |
| 300 | 349 | if (!response.ok) { |
| @@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines | ||
| 64 | 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 65 | 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 66 | 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 | 73 | import { FileSystem, Path as PlatformPath } from "effect"; |
| 69 | 74 | import type { PlatformError } from "effect/PlatformError"; |
| 70 | 75 | import * as Effect from "effect/Effect"; |
| @@ -79,10 +84,13 @@ import { · 8 unmodified lines | ||
| 79 | 84 | getExecutorServerAuthorizationHeader, |
| 80 | 85 | normalizeExecutorServerConnection, |
| 81 | 86 | normalizeExecutorServerOrigin, |
| 87 | + resolveExecutorServerConfiguredHeaders, | |
| 88 | + resolveExecutorServerRequestHeaders, | |
| 82 | 89 | type ExecutorLocalServerKind, |
| 83 | 90 | type ExecutorLocalServerManifest, |
| 84 | 91 | type ExecutorServerConnection, |
| 85 | 92 | type ExecutorServerConnectionInput, |
| 93 | + type ExecutorServerHeaders, | |
| 86 | 94 | } from "@executor-js/sdk/shared"; |
| 87 | 95 | import { |
| 88 | 96 | decodeAccessTokenClaims, |
| @@ -131,7 +139,9 @@ import { · 42 unmodified lines | ||
| 131 | 139 | import { |
| 132 | 140 | canAutoStartCliServerConnection, |
| 133 | 141 | chooseCliServerConnectionWithActiveLocal, |
| 142 | + describeUnauthorizedCliServer, | |
| 134 | 143 | parseCliExecutorServerConnection, |
| 144 | + profileNameFromConnectionKey, | |
| 135 | 145 | type CliServerConnectionSource, |
| 136 | 146 | withCliServerAuthFallback, |
| 137 | 147 | } from "./server-connection"; |
| @@ -162,7 +172,7 @@ import { · 24 unmodified lines | ||
| 162 | 172 | buildDescribeToolCode, |
| 163 | 173 | filterToolPathChildren, |
| 164 | 174 | buildInvokeToolCode, |
| 165 | − buildListSourcesCode, | |
| 175 | + buildListIntegrationsCode, | |
| 166 | 176 | buildSearchToolsCode, |
| 167 | 177 | extractExecutionId, |
| 168 | 178 | extractPausedInteraction, |
| @@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines | ||
| 205 | 215 | const shutdown = () => resume(Effect.void); |
| 206 | 216 | process.once("SIGINT", shutdown); |
| 207 | 217 | process.once("SIGTERM", shutdown); |
| 218 | + process.once("SIGHUP", shutdown); | |
| 208 | 219 | return Effect.sync(() => { |
| 209 | 220 | process.off("SIGINT", shutdown); |
| 210 | 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 | 755 | // login. The refreshed tokens are written back to the originating profile. |
| 744 | 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 | 758 | const refreshOAuthConnection = ( |
| 750 | 759 | connection: ExecutorServerConnection, |
| 751 | −): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | |
| 760 | +): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => | |
| 752 | 761 | Effect.gen(function* () { |
| 753 | 762 | const auth = connection.auth; |
| 754 | 763 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | ||
| 759 | 768 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 760 | 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 | 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 | 784 | catch: toError, |
| 765 | 785 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 766 | 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 | 804 | if (profileName) { |
| 785 | 805 | yield* upsertCliServerConnectionProfile({ |
| 786 | 806 | name: profileName, |
| @@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines | ||
| 961 | 981 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 962 | 982 | Effect.gen(function* () { |
| 963 | 983 | const connection = yield* resolveExecutorServerConnection(input.target); |
| 964 | − const client = yield* makeApiClient(connection); | |
| 984 | + const client = yield* makeApiClient(connection, input.target); | |
| 965 | 985 | const response = yield* client.executions.execute({ |
| 966 | 986 | payload: { |
| 967 | 987 | code: input.code, |
| @@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines | ||
| 1050 | 1070 | // Typed API client |
| 1051 | 1071 | // --------------------------------------------------------------------------- |
| 1052 | 1072 | |
| 1053 | −const makeApiClient = (connection: ExecutorServerConnection) => { | |
| 1054 | − const authorization = getExecutorServerAuthorizationHeader(connection); | |
| 1055 | − return HttpApiClient.make(ExecutorApi, { | |
| 1056 | − baseUrl: connection.apiBaseUrl, | |
| 1057 | − ...(authorization | |
| 1058 | − ? { | |
| 1059 | − transformClient: HttpClient.mapRequest((request) => | |
| 1060 | − HttpClientRequest.setHeader(request, "authorization", authorization), | |
| 1061 | − ), | |
| 1062 | − } | |
| 1063 | − : {}), | |
| 1073 | +const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => | |
| 1074 | + Effect.gen(function* () { | |
| 1075 | + const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( | |
| 1076 | + Effect.mapError(toError), | |
| 1077 | + ); | |
| 1078 | + return yield* HttpApiClient.make(ExecutorApi, { | |
| 1079 | + baseUrl: connection.apiBaseUrl, | |
| 1080 | + ...(Object.keys(headers).length > 0 | |
| 1081 | + ? { | |
| 1082 | + transformClient: HttpClient.mapRequest((request) => | |
| 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 | 1102 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1065 | −}; | |
| 1066 | 1103 | |
| 1067 | 1104 | // --------------------------------------------------------------------------- |
| 1068 | 1105 | // Foreground session |
| @@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines | ||
| 1321 | 1358 | // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. |
| 1322 | 1359 | // --------------------------------------------------------------------------- |
| 1323 | 1360 | |
| 1324 | −const mcpUrlForActiveLocalServer = ( | |
| 1325 | − connection: ExecutorServerConnection, | |
| 1326 | − elicitationMode: "browser" | "model", | |
| 1327 | −): URL => { | |
| 1328 | − const url = new URL("/mcp", connection.origin); | |
| 1329 | − if (elicitationMode === "browser") { | |
| 1361 | +const mcpUrlForActiveLocalServer = (input: { | |
| 1362 | + readonly connection: ExecutorServerConnection; | |
| 1363 | + readonly elicitationMode: "browser" | "model"; | |
| 1364 | + readonly artifacts: boolean; | |
| 1365 | + readonly searchTools: boolean; | |
| 1366 | +}): URL => { | |
| 1367 | + const url = new URL("/mcp", input.connection.origin); | |
| 1368 | + if (input.elicitationMode === "browser") { | |
| 1330 | 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 | 1381 | return url; |
| 1333 | 1382 | }; |
| 1334 | 1383 | |
| @@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines | ||
| 1343 | 1392 | const runMcpHttpBridge = async (input: { |
| 1344 | 1393 | readonly manifest: ExecutorLocalServerManifest; |
| 1345 | 1394 | readonly elicitationMode: "browser" | "model"; |
| 1395 | + readonly artifacts: boolean; | |
| 1396 | + readonly searchTools: boolean; | |
| 1346 | 1397 | }): Promise<void> => { |
| 1347 | 1398 | const stdio = new StdioServerTransport(); |
| 1348 | 1399 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); |
| 1349 | 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 | 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 | 1486 | Effect.gen(function* () { |
| 1427 | 1487 | // `executor mcp` never owns the local database. If a local server is already |
| 1428 | 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 | 1494 | const active = yield* readActiveLocalServerManifest(); |
| 1435 | 1495 | if (active) { |
| 1436 | 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 | 1504 | return; |
| 1440 | 1505 | } |
| @@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines | ||
| 1456 | 1521 | ); |
| 1457 | 1522 | } |
| 1458 | 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 | 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 | 1593 | const formatUnknownMessage = (cause: unknown): string => { |
| 1508 | − if (cause instanceof Error) return cause.message; | |
| 1509 | − if (typeof cause === "string") return cause; | |
| 1510 | − if (typeof cause === "object" && cause !== null && "message" in cause) { | |
| 1511 | − const message = cause.message; | |
| 1512 | − if (typeof message === "string") return message; | |
| 1594 | + const messages: string[] = []; | |
| 1595 | + const seen = new Set<unknown>(); | |
| 1596 | + let current: unknown = cause; | |
| 1597 | + while (current !== null && current !== undefined && !seen.has(current)) { | |
| 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 | 1618 | const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => { |
| @@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines | ||
| 1802 | 1903 | Effect.gen(function* () { |
| 1803 | 1904 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); |
| 1804 | 1905 | |
| 1805 | − const connection = yield* resolveExecutorServerConnection({ | |
| 1806 | − baseUrl: args.baseUrl, | |
| 1807 | − serverName: args.serverName, | |
| 1808 | − }); | |
| 1809 | − const client = yield* makeApiClient(connection); | |
| 1906 | + const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName }; | |
| 1907 | + const connection = yield* resolveExecutorServerConnection(target); | |
| 1908 | + const client = yield* makeApiClient(connection, target); | |
| 1810 | 1909 | const tools = yield* client.tools.list({ query: {} }); |
| 1811 | 1910 | const toolPaths = tools.map((tool) => tool.address); |
| 1812 | 1911 | |
| @@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines | ||
| 1987 | 2086 | |
| 1988 | 2087 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); |
| 1989 | 2088 | |
| 1990 | − const client = yield* makeApiClient(connection); | |
| 2089 | + const client = yield* makeApiClient(connection, target); | |
| 1991 | 2090 | const result = yield* client.executions.resume({ |
| 1992 | 2091 | params: { executionId }, |
| 1993 | 2092 | payload: { action, content: contentObj }, |
| @@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines | ||
| 2052 | 2151 | }), |
| 2053 | 2152 | ).pipe(Command.withDescription("Search tools by natural-language query")); |
| 2054 | 2153 | |
| 2055 | −const toolsSourcesCommand = Command.make( | |
| 2056 | − "sources", | |
| 2154 | +const toolsIntegrationsCommand = Command.make( | |
| 2155 | + "integrations", | |
| 2057 | 2156 | { |
| 2058 | 2157 | query: Options.string("query").pipe(Options.optional), |
| 2059 | 2158 | limit: Options.integer("limit").pipe(Options.withDefault(50)), |
| @@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | ||
| 2065 | 2164 | Effect.gen(function* () { |
| 2066 | 2165 | applyScope(scope); |
| 2067 | 2166 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 2068 | − const code = buildListSourcesCode({ | |
| 2167 | + const code = buildListIntegrationsCode({ | |
| 2069 | 2168 | query: Option.getOrUndefined(query), |
| 2070 | 2169 | limit, |
| 2071 | 2170 | }); |
| @@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | ||
| 2077 | 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 | 2181 | const toolsDescribeCommand = Command.make( |
| 2083 | 2182 | "describe", |
| @@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines | ||
| 2102 | 2201 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); |
| 2103 | 2202 | |
| 2104 | 2203 | const toolsCommand = Command.make("tools").pipe( |
| 2105 | − Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const), | |
| 2106 | − Command.withDescription("Discover available tools and sources"), | |
| 2204 | + Command.withSubcommands([ | |
| 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 | 2239 | const profileConnectionInput = (input: { |
| 2110 | 2240 | readonly origin: string; |
| 2111 | 2241 | readonly displayName: Option.Option<string>; |
| 2112 | 2242 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2243 | + readonly headerEnv: Option.Option<Record<string, string>>; | |
| 2113 | 2244 | }): ExecutorServerConnectionInput => { |
| 2114 | 2245 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2115 | 2246 | const displayName = Option.getOrUndefined(input.displayName); |
| 2247 | + const headers = parseHeaderEnvOption(input.headerEnv); | |
| 2116 | 2248 | return { |
| 2117 | 2249 | kind: selectedKind ?? "http", |
| 2118 | 2250 | origin: input.origin, |
| 2119 | 2251 | ...(displayName ? { displayName } : {}), |
| 2252 | + ...(headers ? { headers } : {}), | |
| 2120 | 2253 | }; |
| 2121 | 2254 | }; |
| 2122 | 2255 | |
| @@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines | ||
| 2136 | 2269 | origin: profile.connection.origin, |
| 2137 | 2270 | displayName: profile.connection.displayName, |
| 2138 | 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 | 2276 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2141 | 2277 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2142 | 2278 | |
| 2143 | 2279 | for (const row of rows) { |
| 2144 | 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 | 2296 | Options.optional, |
| 2161 | 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 | 2305 | makeDefault: Options.boolean("default").pipe( |
| 2164 | 2306 | Options.withDefault(false), |
| 2165 | 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 | 2311 | Effect.gen(function* () { |
| 2170 | 2312 | const profileName = validateCliServerConnectionProfileName(name); |
| 2171 | 2313 | const store = yield* upsertCliServerConnectionProfile({ |
| 2172 | 2314 | name: profileName, |
| 2173 | − connection: profileConnectionInput({ origin, displayName, kind }), | |
| 2315 | + connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), | |
| 2174 | 2316 | makeDefault, |
| 2175 | 2317 | }); |
| 2176 | 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 | 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 | |
| 2312 | −// later, from the authenticated account. | |
| 2456 | +// or the default), a bare origin (--base-url), or hosted Executor when | |
| 2457 | +// nothing is configured yet. The profile name is decided later, from the | |
| 2458 | +// authenticated account. | |
| 2313 | 2459 | const resolveLoginOrigin = (input: { |
| 2314 | 2460 | readonly baseUrl: Option.Option<string>; |
| 2315 | 2461 | readonly server: Option.Option<string>; |
| @@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines | ||
| 2337 | 2483 | const store = yield* readCliServerConnectionStore(); |
| 2338 | 2484 | const profile = defaultCliServerConnectionProfile(store); |
| 2339 | 2485 | if (profile) return { origin: profile.connection.origin, profile }; |
| 2340 | − return yield* Effect.fail( | |
| 2341 | − new Error( | |
| 2342 | − "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.", | |
| 2343 | − ), | |
| 2344 | − ); | |
| 2486 | + return { origin: DEFAULT_LOGIN_ORIGIN, profile: null }; | |
| 2345 | 2487 | }); |
| 2346 | 2488 | |
| 2347 | 2489 | const loginNameOption = Options.string("name").pipe( |
| @@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines | ||
| 2366 | 2508 | Effect.gen(function* () { |
| 2367 | 2509 | const target = yield* resolveLoginOrigin({ baseUrl, server }); |
| 2368 | 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 | 2520 | const discovery = yield* Effect.tryPromise({ |
| 2370 | − try: () => discoverCliLogin(target.origin), | |
| 2521 | + try: () => discoverCliLogin(target.origin, { headers }), | |
| 2371 | 2522 | catch: toError, |
| 2372 | 2523 | }); |
| 2373 | 2524 | const grant = yield* Effect.tryPromise({ |
| 2374 | − try: () => requestDeviceCode(discovery), | |
| 2525 | + try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), | |
| 2375 | 2526 | catch: toError, |
| 2376 | 2527 | }); |
| 2377 | 2528 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines | ||
| 2382 | 2533 | if (!noBrowser) openBrowser(verifyUrl); |
| 2383 | 2534 | console.log("Waiting for you to approve in the browser..."); |
| 2384 | 2535 | const tokens = yield* Effect.tryPromise({ |
| 2385 | − try: () => pollForDeviceTokens(discovery, grant), | |
| 2536 | + try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), | |
| 2386 | 2537 | catch: toError, |
| 2387 | 2538 | }); |
| 2388 | 2539 | |
| @@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines | ||
| 2407 | 2558 | kind: "http", |
| 2408 | 2559 | origin: target.origin, |
| 2409 | 2560 | ...(email ? { displayName: email } : {}), |
| 2561 | + ...(target.profile?.connection.headers | |
| 2562 | + ? { headers: target.profile.connection.headers } | |
| 2563 | + : {}), | |
| 2410 | 2564 | auth: { |
| 2411 | 2565 | kind: "oauth", |
| 2412 | 2566 | accessToken: tokens.accessToken, |
| @@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines | ||
| 2420 | 2574 | }); |
| 2421 | 2575 | |
| 2422 | 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 | 2586 | if (email) console.log(`Account: ${email}`); |
| 2425 | 2587 | if (org) console.log(`Organization: ${org}`); |
| 2426 | 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 | 2596 | const logoutCommand = Command.make( |
| 2431 | 2597 | "logout", |
| @@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines | ||
| 2441 | 2607 | console.log(`No stored login for ${target.origin}.`); |
| 2442 | 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 | 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 | 2618 | return; |
| 2447 | 2619 | } |
| 2448 | 2620 | yield* upsertCliServerConnectionProfile({ |
| @@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines | ||
| 2451 | 2623 | kind: profile.connection.kind, |
| 2452 | 2624 | origin: profile.connection.origin, |
| 2453 | 2625 | displayName: profile.connection.displayName, |
| 2626 | + ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | |
| 2454 | 2627 | }, |
| 2455 | 2628 | makeDefault: store.defaultProfile === profile.name, |
| 2456 | 2629 | }); |
| 2457 | 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 | 2636 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); |
| @@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines | ||
| 2709 | 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 | 2903 | Effect.gen(function* () { |
| 2715 | 2904 | applyScope(scope); |
| 2716 | − yield* runStdioMcpSession({ elicitationMode }); | |
| 2905 | + yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); | |
| 2717 | 2906 | }), |
| 2718 | 2907 | ).pipe(Command.withDescription("Start an MCP server over stdio")); |
| 2719 | 2908 | |
| @@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines | ||
| 3164 | 3353 | } else { |
| 3165 | 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 | 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 | |
| 5 | −// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | |
| 6 | −// the binary fails. build.ts copies each platform's `.node` next to the | |
| 7 | −// executable (`libsql.node`, `keyring.node`); here we publish their on-disk | |
| 8 | −// paths via env vars the loaders read. | |
| 4 | +// `bun --compile` bundles JS into bunfs but does NOT include native binaries, | |
| 5 | +// wasm sidecars, or runtime-read package files. build.ts copies those artifacts | |
| 6 | +// next to the executable; here we publish their on-disk paths via env vars the | |
| 7 | +// loaders read. | |
| 9 | 8 | // |
| 10 | 9 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import |
| 11 | 10 | // before the importer's own body, and libSQL resolves its native addon EAGERLY |
| @@ -18,6 +17,8 @@ · 6 unmodified lines | ||
| 18 | 17 | import { existsSync } from "node:fs"; |
| 19 | 18 | import { dirname, join } from "node:path"; |
| 20 | 19 | |
| 20 | +import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | |
| 21 | + | |
| 21 | 22 | const execDir = dirname(process.execPath); |
| 22 | 23 | |
| 23 | 24 | // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the |
| @@ -43,3 +44,33 @@ if ( · 19 unmodified lines | ||
| 43 | 44 | ) { |
| 44 | 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | +import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | |
| 4 | + | |
| 3 | 5 | import { |
| 4 | 6 | canAutoStartCliServerConnection, |
| 5 | 7 | chooseCliServerConnectionWithActiveLocal, |
| 8 | + describeUnauthorizedCliServer, | |
| 6 | 9 | parseCliExecutorServerConnection, |
| 7 | 10 | withCliServerAuthFallback, |
| 8 | 11 | } from "./server-connection"; |
| @@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines | ||
| 135 | 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 | 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 | 104 | export type CliServerConnectionSource = |
| 56 | 105 | | "explicit" |
| 57 | 106 | | "default-profile" |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| @@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines | ||
| 25 | 25 | }); |
| 26 | 26 | |
| 27 | 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 | 29 | Effect.gen(function* () { |
| 30 | 30 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 31 | 31 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines | ||
| 36 | 36 | connection: { |
| 37 | 37 | origin: "https://executor.example/api", |
| 38 | 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 | 43 | makeDefault: true, |
| 41 | 44 | }); |
| @@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | ||
| 50 | 53 | kind: "bearer", |
| 51 | 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 | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | 60 | } finally { |
| 55 | 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 | 67 | Effect.gen(function* () { |
| 61 | 68 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 62 | 69 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines | ||
| 82 | 89 | } finally { |
| 83 | 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 | 95 | it("drops malformed profiles when parsing", () => { |
| 88 | 96 | const store = parseCliServerConnectionStore( |
| @@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines | ||
| 114 | 122 | key: "desktop-sidecar", |
| 115 | 123 | origin: "http://127.0.0.1:4789", |
| 116 | 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 | 141 | username: "executor", |
| 128 | 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 | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | ||
| 69 | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | 75 | displayName: Schema.optional(Schema.String), |
| 71 | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | + headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | |
| 72 | 78 | }); |
| 73 | 79 | |
| 74 | 80 | const PersistedProfile = Schema.Struct({ |
| @@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines | ||
| 144 | 150 | const path = yield* Path.Path; |
| 145 | 151 | const dataDir = resolveDataDir(path); |
| 146 | 152 | yield* fs.makeDirectory(dataDir, { recursive: true }); |
| 147 | − yield* fs.writeFileString( | |
| 148 | − serverConnectionStorePath(path), | |
| 149 | − serializeCliServerConnectionStore(store), | |
| 150 | − ); | |
| 153 | + const storePath = serverConnectionStorePath(path); | |
| 154 | + // This store holds live credentials for a hosted server — a bearer token, or | |
| 155 | + // an OAuth access token AND its long-lived refresh token — so create it | |
| 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 | 171 | export const upsertCliServerConnectionProfile = (input: { |
| @@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines | ||
| 185 | 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 | 218 | it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { |
| 189 | 219 | // A `"` in PATH would close the `set "PATH=..."` quote early and let a |
| 190 | 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 | 177 | "EXECUTOR_SENTRY_RELEASE", |
| 178 | 178 | "EXECUTOR_SENTRY_ENVIRONMENT", |
| 179 | 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 | 191 | ] as const; |
| 181 | 192 | const passThrough = Object.fromEntries( |
| 182 | 193 | passThroughKeys.flatMap((key) => { |
| @@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines | ||
| 408 | 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 | 412 | const payload: Record<string, unknown> = { |
| 413 | 413 | limit: input.limit, |
| 414 | 414 | }; |
| 415 | 415 | if (input.query && input.query.trim().length > 0) { |
| 416 | 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 | 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 | 8 | "skipLibCheck": true, |
| 9 | 9 | "noEmit": true, |
| 10 | 10 | "declaration": true, |
| 11 | − "types": ["bun-types"] | |
| 11 | + "types": ["bun-types", "node"] | |
| 12 | 12 | }, |
| 13 | 13 | "include": ["src", "bin"] |
| 14 | 14 | } |
| @@ -1,5 +1,298 @@ | ||
| 1 | 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 | 296 | ## 1.4.47 |
| 4 | 297 | |
| 5 | 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 | 71 | "when": 1783025157233, |
| 72 | 72 | "tag": "0009_true_lake", |
| 73 | 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 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { | |
| 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 | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 13 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines | ||
| 42 | 48 | * falls back to the credential-driven default. */ |
| 43 | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | 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 | 53 | export default defineExecutorConfig({ |
| 52 | − plugins: ({ | |
| 53 | − workosCredentials, | |
| 54 | − workosVaultClient, | |
| 55 | − activeToolkitSlug, | |
| 56 | − allowLocalNetwork, | |
| 57 | − }: CloudPluginDeps = {}) => | |
| 54 | + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => | |
| 58 | 55 | [ |
| 59 | − openApiHttpPlugin(), | |
| 60 | − googleHttpPlugin(), | |
| 61 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | |
| 56 | + openApiHttpPlugin({ | |
| 57 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 58 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 59 | + }), | |
| 62 | 60 | mcpHttpPlugin({ |
| 63 | 61 | dangerouslyAllowStdioMCP: false, |
| 64 | 62 | }), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/cloud", |
| 3 | − "version": "1.4.47", | |
| 3 | + "version": "1.4.61", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| @@ -30,7 +30,10 @@ · 23 unmodified lines | ||
| 30 | 30 | "typecheck:slow": "tsc --noEmit", |
| 31 | 31 | "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", |
| 32 | 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 | 38 | "dependencies": { |
| 36 | 39 | "@cloudflare/vite-plugin": "^1.31.1", |
| @@ -41,10 +44,9 @@ · 4 unmodified lines | ||
| 41 | 44 | "@executor-js/execution": "workspace:*", |
| 42 | 45 | "@executor-js/fumadb": "workspace:*", |
| 43 | 46 | "@executor-js/host-mcp": "workspace:*", |
| 44 | − "@executor-js/plugin-google": "workspace:*", | |
| 47 | + "@executor-js/mcp-apps-shell": "workspace:*", | |
| 45 | 48 | "@executor-js/plugin-graphql": "workspace:*", |
| 46 | 49 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | − "@executor-js/plugin-microsoft": "workspace:*", | |
| 48 | 50 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | 51 | "@executor-js/plugin-toolkits": "workspace:*", |
| 50 | 52 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -53,6 +55,7 @@ · 2 unmodified lines | ||
| 53 | 55 | "@executor-js/runtime-quickjs": "workspace:*", |
| 54 | 56 | "@executor-js/sdk": "workspace:*", |
| 55 | 57 | "@executor-js/vite-plugin": "workspace:*", |
| 58 | + "@jitl/quickjs-wasmfile-release-sync": "catalog:", | |
| 56 | 59 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 57 | 60 | "@opentelemetry/api": "~1.9.0", |
| 58 | 61 | "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", |
| @@ -70,9 +73,11 @@ · 11 unmodified lines | ||
| 70 | 73 | "autumn-js": "^1.2.8", |
| 71 | 74 | "drizzle-orm": "catalog:", |
| 72 | 75 | "effect": "catalog:", |
| 76 | + "iron-webcrypto": "2.0.0", | |
| 73 | 77 | "jose": "^5.6.3", |
| 74 | 78 | "postgres": "^3.4.9", |
| 75 | 79 | "posthog-js": "^1.372.5", |
| 80 | + "quickjs-emscripten-core": "0.31.0", | |
| 76 | 81 | "react": "catalog:", |
| 77 | 82 | "react-dom": "catalog:", |
| 78 | 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 | 10 | import { spawnSync } from "node:child_process"; |
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | −import { rmSync } from "node:fs"; | |
| 12 | +import { existsSync, readFileSync, rmSync } from "node:fs"; | |
| 13 | 13 | |
| 14 | 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { |
| 15 | 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); |
| @@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines | ||
| 32 | 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 | 15 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| 16 | 16 | import { drizzle } from "drizzle-orm/pglite"; |
| 17 | 17 | import { migrate } from "drizzle-orm/pglite/migrator"; |
| 18 | +import postgres from "postgres"; | |
| 18 | 19 | |
| 19 | 20 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 20 | 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 | 105 | // but prepared statement requires M" -> random 500s on whichever request lost |
| 105 | 106 | // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch |
| 106 | 107 | // batches each socket data event into one queue entry and holds handler |
| 107 | −// affinity while a pipeline is open; | |
| 108 | −// src/db/dev-db-socket-concurrency.node.test.ts is the regression test. | |
| 109 | −const server = new PGLiteSocketServer({ | |
| 110 | − db, | |
| 111 | − port: PORT, | |
| 112 | − host: "127.0.0.1", | |
| 113 | − maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), | |
| 114 | − // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse | |
| 115 | − // sent, no Sync) with its socket still OPEN would hold the queue's handler | |
| 116 | − // affinity forever and starve every other connection, since affinity only | |
| 117 | − // releases on detach and detach needs close/error/idle-timeout. In ms; the | |
| 118 | − // timer resets on every data event, so only a genuinely dead client trips it. | |
| 119 | − idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), | |
| 120 | −}); | |
| 108 | +// affinity while a pipeline is open. The patch also fixes the queue's two | |
| 109 | +// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn: | |
| 110 | +// callback set no session (500)" cascade, where new connections' startup | |
| 111 | +// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart: | |
| 112 | +// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at | |
| 113 | +// the JS level, leaving its `processing` flag latched true; nothing was | |
| 114 | +// ever dequeued again. | |
| 115 | +// 2. A client whose socket died WHILE its pipeline-opening entry executed: | |
| 116 | +// detach() cleared affinity before the entry finished, the queue then | |
| 117 | +// took affinity for the already-dead handler, and no timer was left to | |
| 118 | +// release it. The queue now tracks detached handlers and repairs any | |
| 119 | +// transaction or pipeline affinity they can no longer release. | |
| 120 | +// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for | |
| 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 | 141 | await server.start(); |
| 123 | 142 | console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); |
| 124 | 143 | |
| 144 | +let stopping = false; | |
| 145 | + | |
| 125 | 146 | const shutdown = async () => { |
| 147 | + stopping = true; | |
| 126 | 148 | console.log("\n[dev-db] Shutting down"); |
| 127 | 149 | await server.stop(); |
| 128 | 150 | await db.close(); |
| @@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines | ||
| 131 | 153 | |
| 132 | 154 | process.on("SIGINT", shutdown); |
| 133 | 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 | 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 | 27 | const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); |
| 18 | 28 | |
| 19 | 29 | let db: PGlite | undefined; |
| 20 | 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 | 36 | export default async function setup() { |
| 23 | 37 | db = await PGlite.create(); |
| 24 | 38 | await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); |
| @@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines | ||
| 30 | 44 | console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); |
| 31 | 45 | |
| 32 | 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 | 51 | await server?.stop(); |
| 34 | 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 | 91 | ? Effect.succeed(caller.session) |
| 92 | 92 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); |
| 93 | 93 | |
| 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 | |
| 96 | − // is re-checked live, so the header is a selector, not a trust boundary — | |
| 94 | + // The org scope for an org-scoped request: the console URL's org, sent in | |
| 95 | + // the selector header. FAIL CLOSED when it's missing — the session's own | |
| 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 | 100 | // and two browser tabs on different orgs each send their own header, so |
| 98 | 101 | // they stay independent (see organization.ts). Yields the session + |
| 99 | 102 | // resolved org, or AccountNoOrganization. |
| 100 | 103 | const requireOrganization = (headers: AccountHeaders) => |
| 101 | 104 | Effect.gen(function* () { |
| 102 | 105 | const session = yield* requireSession(); |
| 103 | − const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | |
| 106 | + const selector = headers[ORG_SELECTOR_HEADER]; | |
| 104 | 107 | if (!selector) { |
| 105 | 108 | return yield* new AccountNoOrganization(); |
| 106 | 109 | } |
| @@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines | ||
| 180 | 183 | me: (headers) => |
| 181 | 184 | Effect.gen(function* () { |
| 182 | 185 | const session = yield* requireSession(); |
| 183 | − // Same selector precedence as requireOrganization: the URL's org | |
| 184 | − // (header) drives /account/me so the shell reflects the org the tab | |
| 185 | − // is viewing, not a session-global active org. | |
| 186 | + // The ONE sanctioned fallback to the session org. /account/me is | |
| 187 | + // identity, not tenant data: on a bare URL (no org slug yet, so no | |
| 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 | 195 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; |
| 187 | 196 | const org = selector |
| 188 | 197 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| @@ -237,6 +246,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines | ||
| 237 | 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 | 295 | listMembers: (headers) => |
| 241 | 296 | Effect.gen(function* () { |
| 242 | 297 | const { session, org } = yield* requireOrganization(headers); |
| @@ -331,7 +386,9 @@ export const workosAccountProvider: Layer.Layer< · 88 unmodified lines | ||
| 331 | 386 | .updateOrganization(org.id, name) |
| 332 | 387 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| 333 | 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 | 392 | .pipe(Effect.catchTag("UserStoreError", toAccountError)); |
| 336 | 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 | 13 | Effect.succeed( |
| 14 | 14 | value === "valid_user_key" |
| 15 | 15 | ? { |
| 16 | + scope: "user" as const, | |
| 16 | 17 | accountId: "user_123", |
| 17 | 18 | organizationId: "org_123", |
| 18 | 19 | keyId: "api_key_123", |
| @@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines | ||
| 22 | 23 | listUserKeys: () => Effect.succeed([]), |
| 23 | 24 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), |
| 24 | 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 | 31 | const stubWorkOS = Layer.succeed( |
| @@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | ||
| 43 | 47 | ); |
| 44 | 48 | |
| 45 | 49 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 46 | − use: (fn) => | |
| 50 | + use: (_op, fn) => | |
| 47 | 51 | Effect.promise(() => |
| 48 | 52 | fn({ |
| 49 | 53 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -85,6 +89,7 @@ describe("protected API key auth", () => { · 35 unmodified lines | ||
| 85 | 89 | ); |
| 86 | 90 | |
| 87 | 91 | expect(identity).toEqual({ |
| 92 | + kind: "member", | |
| 88 | 93 | accountId: "user_123", |
| 89 | 94 | organizationId: "org_123", |
| 90 | 95 | organizationName: "Org org_123", |
| @@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines | ||
| 40 | 40 | listUserKeys: () => Effect.succeed([]), |
| 41 | 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), |
| 42 | 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 | 48 | const stubWorkOS = Layer.succeed( |
| @@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | ||
| 61 | 64 | ); |
| 62 | 65 | |
| 63 | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 64 | − use: (fn) => | |
| 67 | + use: (_op, fn) => | |
| 65 | 68 | Effect.promise(() => |
| 66 | 69 | fn({ |
| 67 | 70 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -107,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 39 unmodified lines | ||
| 107 | 110 | const identity = yield* run(request(token), config); |
| 108 | 111 | |
| 109 | 112 | expect(identity).toEqual({ |
| 113 | + kind: "member", | |
| 110 | 114 | accountId: "user_123", |
| 111 | 115 | organizationId: "org_123", |
| 112 | 116 | organizationName: "Org org_123", |
| @@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines | ||
| 20 | 20 | pausedExecutionCount: () => Effect.succeed(0), |
| 21 | 21 | hasPausedExecutions: () => Effect.succeed(false), |
| 22 | 22 | getDescription: Effect.succeed("desc"), |
| 23 | + // The fake forks nothing, so there is no sandbox fiber to end. | |
| 24 | + shutdown: Effect.void, | |
| 23 | 25 | }) as ExecutionEngine; |
| 24 | 26 | |
| 25 | 27 | describe("withExecutionUsageTracking", () => { |
| @@ -1,6 +1,6 @@ | ||
| 1 | 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 | 5 | // Guards the start.ts dispatch decision: every surface the unified app handler |
| 6 | 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 | 18 | export const isAppOwnedPath = (pathname: string) => |
| 19 | 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 | 13 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; |
| 14 | 14 | import { DbService } from "./db/db"; |
| 15 | 15 | import { cloudMcpAuth } from "./mcp"; |
| 16 | −import { McpSessionDOSqlite } from "./mcp/session-durable-object"; | |
| 17 | 16 | import { ErrorCaptureLive } from "./observability"; |
| 18 | 17 | import { AutumnService } from "./extensions/billing/service"; |
| 19 | 18 | import { |
| @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines | ||
| 31 | 30 | // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- |
| 32 | 31 | // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, |
| 33 | 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 | 34 | // capture — and Autumn BILLING entering ONLY as extensions: the engine |
| 36 | 35 | // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, |
| 37 | 36 | // and the createOrganization free-limit gate. `diff` against |
| @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines | ||
| 66 | 65 | // satisfied by `boot`, `DbService` per request via `requestScoped`). |
| 67 | 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 | 69 | plugins: cloudPlugins, |
| 71 | 70 | providers: { |
| 72 | 71 | // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS |
| @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines | ||
| 112 | 111 | failure: cloudIdentityFailureStrategy, |
| 113 | 112 | // The MCP session Durable Object class — a top-level Workers export a Layer |
| 114 | 113 | // can't return; surfaced so `server.ts` can re-export it. |
| 115 | − mcpExport: McpSessionDOSqlite, | |
| 116 | 114 | }, |
| 117 | 115 | // The long-lived (boot-scoped) context provideMerge'd under everything: the |
| 118 | 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 | 133 | requestScoped: RequestScopedServicesLive, |
| 136 | 134 | }); |
| 137 | 135 | |
| 138 | −export { McpSessionDOSqlite }; | |
| 139 | − | |
| 140 | 136 | export const CloudAppLayer = appLayer; |
| 141 | −export const cloudMcpExport = mcpExport; | |
| 142 | 137 | |
| 143 | 138 | // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), |
| 144 | 139 | // /mcp, /.well-known/* — everything the worker dispatches. |
| @@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines | ||
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | 44 | expect(principal).toEqual({ |
| 45 | + scope: "user", | |
| 45 | 46 | accountId: "user_123", |
| 46 | 47 | organizationId: "org_123", |
| 47 | 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 | 71 | Effect.gen(function* () { |
| 71 | 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 | 73 | const orgLess = yield* validate({ |
| 79 | 74 | apiKey: { |
| 80 | 75 | id: "api_key_no_org", |
| @@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines | ||
| 83 | 78 | }); |
| 84 | 79 | |
| 85 | 80 | expect(missing).toBeNull(); |
| 86 | − expect(orgOwned).toBeNull(); | |
| 87 | 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 | 106 | it.effect("lists and creates user-owned keys", () => |
| 92 | 107 | Effect.gen(function* () { |
| 93 | 108 | const program = Effect.gen(function* () { |
| @@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { ApiKeyManagementError } from "./errors"; |
| 4 | 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 | 16 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / |
| 7 | 17 | * name / roles), so it carries an honest, distinct name. */ |
| 8 | 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 | 24 | readonly organizationId: string; |
| 11 | 25 | readonly keyId: string; |
| 12 | 26 | }; |
| @@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines | ||
| 28 | 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 | 60 | const UserApiKeyOwner = Schema.Struct({ |
| 32 | 61 | type: Schema.Literal("user"), |
| 33 | 62 | id: Schema.String, |
| @@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line | ||
| 35 | 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 | 74 | const ApiKey = Schema.Struct({ |
| 39 | 75 | id: Schema.String, |
| 40 | 76 | owner: UserApiKeyOwner, |
| @@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines | ||
| 49 | 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 | 97 | const ValidateApiKeyResponse = Schema.Struct({ |
| 53 | − apiKey: Schema.NullOr(ApiKey), | |
| 98 | + apiKey: Schema.NullOr(ValidatedApiKey), | |
| 54 | 99 | }); |
| 55 | 100 | |
| 56 | 101 | const RawCreatedApiKey = Schema.Struct({ |
| @@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines | ||
| 72 | 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 | 159 | const CreateApiKeyResponse = Schema.Union([ |
| 76 | 160 | RawCreatedApiKey, |
| 77 | 161 | Schema.Struct({ apiKey: RawCreatedApiKey }), |
| @@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines | ||
| 81 | 165 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); |
| 82 | 166 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); |
| 83 | 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 | 193 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => |
| 86 | 194 | Option.match(decodeValidateApiKeyResponse(value), { |
| 87 | 195 | onNone: () => null, |
| 88 | − onSome: ({ apiKey }) => { | |
| 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 | − }, | |
| 196 | + onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), | |
| 98 | 197 | }); |
| 99 | 198 | |
| 100 | 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 | 269 | const createdFromResponse = (value: unknown): CreatedApiKey | null => |
| 124 | 270 | Option.match(decodeCreateApiKeyResponse(value), { |
| 125 | 271 | onNone: () => null, |
| @@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines | ||
| 147 | 293 | readonly revokeUserKey: (input: { |
| 148 | 294 | readonly keyId: string; |
| 149 | 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 | 319 | >()("@executor-js/cloud/ApiKeyService") { |
| 152 | 320 | static WorkOS = Layer.effect(this)( |
| @@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines | ||
| 177 | 345 | workos |
| 178 | 346 | .deleteApiKey(keyId) |
| 179 | 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 | 181 | /** Public auth endpoints — no authentication required */ |
| 182 | 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") |
| 183 | 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 | 193 | .add( |
| 185 | 194 | HttpApiEndpoint.get("callback", "/auth/callback", { |
| 186 | 195 | query: AuthCallbackSearch, |
| @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 14 unmodified lines | ||
| 201 | 210 | error: AuthErrors, |
| 202 | 211 | }), |
| 203 | 212 | ) |
| 204 | − .add(HttpApiEndpoint.post("logout", "/auth/logout")) | |
| 205 | 213 | .add( |
| 206 | 214 | HttpApiEndpoint.get("organizations", "/auth/organizations", { |
| 207 | 215 | success: AuthOrganizationsResponse, |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | import { makeUserStore } from "../auth/user-store"; |
| 3 | 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 | 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 | 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 | 17 | const makeService = (store: RawStore) => ({ |
| 13 | − use: <A>(fn: (s: RawStore) => Promise<A>) => | |
| 18 | + use: <A>(op: string, fn: (s: RawStore) => Promise<A>) => | |
| 14 | 19 | withServiceLogging( |
| 15 | − "user_store", | |
| 16 | − () => new UserStoreError(), | |
| 20 | + `user_store.${op}`, | |
| 21 | + (failure) => userStoreErrorFromFailure(op, failure), | |
| 17 | 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 | 4 | // The sealed `wos-session` cookie is verified right here in the worker |
| 5 | 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 | |
| 7 | −// who it's serving: | |
| 6 | +// except token refresh), so by the time the SPA shell is served the server | |
| 7 | +// KNOWS who it's serving: | |
| 8 | 8 | // |
| 9 | 9 | // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists |
| 10 | 10 | // - org-less → 302 /create-org (onboarding owns those sessions) |
| 11 | −// - signed in → the document is served WITH the verified identity: the | |
| 12 | −// auth-hint travels to the SSR render via request-middleware context (the | |
| 13 | −// root loader picks it up), and is minted as a cookie when the browser | |
| 14 | −// doesn't hold a current one — so the very first paint is the real app | |
| 15 | −// shell, never a skeleton. The hint is display-only; /account/me remains | |
| 16 | −// the authority and the client keeps it fresh from then on. | |
| 11 | +// - signed in → the prerendered shell is served, and the auth-hint cookie is | |
| 12 | +// minted when the browser doesn't hold a current one — the client seeds its | |
| 13 | +// auth state from that cookie right after mount (there is no per-request | |
| 14 | +// render to dehydrate it into). The hint is display-only; /account/me | |
| 15 | +// remains 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 | 22 | // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / |
| 19 | 23 | // accept), excluding app-owned paths (/api, /mcp — they answer for themselves |
| @@ -23,6 +27,7 @@ · 3 unmodified lines | ||
| 23 | 27 | import { createMiddleware } from "@tanstack/react-start"; |
| 24 | 28 | import { Effect, Exit, Layer, ManagedRuntime } from "effect"; |
| 25 | 29 | |
| 30 | +import { isValidOrgSlug } from "@executor-js/api"; | |
| 26 | 31 | import { |
| 27 | 32 | AUTH_HINT_COOKIE, |
| 28 | 33 | AUTH_HINT_MAX_AGE_SECONDS, |
| @@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines | ||
| 35 | 40 | import { makeDbLayer } from "../db/db"; |
| 36 | 41 | import { makeUserStoreLayer, UserStoreService } from "./context"; |
| 37 | 42 | import { parseCookie } from "./cookies"; |
| 43 | +import { LAST_ORG_COOKIE } from "./last-org-cookie"; | |
| 38 | 44 | import { sealedSessionDisplayName } from "./middleware"; |
| 39 | −import { browserOriginFromRequest } from "./request-origin"; | |
| 45 | +import { authorizeOrganizationSelector } from "./organization"; | |
| 40 | 46 | import { loginPath, safeReturnTo } from "./return-to"; |
| 41 | 47 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; |
| 42 | 48 | import { WorkOSClient } from "./workos"; |
| @@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines | ||
| 150 | 156 | ): Promise<{ name: string; slug: string }> => { |
| 151 | 157 | const exit = await getRuntime().runPromiseExit( |
| 152 | 158 | Effect.flatMap(UserStoreService.asEffect(), (users) => |
| 153 | − users.use((store) => store.getOrganization(organizationId)), | |
| 159 | + users.use("getOrganization", (store) => store.getOrganization(organizationId)), | |
| 154 | 160 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), |
| 155 | 161 | ); |
| 156 | 162 | return Exit.isSuccess(exit) |
| @@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line | ||
| 158 | 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 | 184 | const hintSetCookie = (hint: AuthHint) => |
| 162 | 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 | 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 | |
| 230 | − // SSR render through middleware context (the root loader reads it), so | |
| 231 | − // the server paints the real authenticated shell — no loading state, no | |
| 232 | − // skeleton. The request origin rides along too: it's what the connect | |
| 233 | − // card's MCP URL is built from, and the server knows it (the SPA only | |
| 234 | − // learns `window.location.origin` after mount), so passing it here lets | |
| 235 | − // SSR render the real `https://…/<org>/mcp` instead of the client-side | |
| 236 | − // `http://127.0.0.1:4000` default — which would otherwise flash until | |
| 237 | − // hydration corrected it. Set-cookie writes ride on the rendered response. | |
| 252 | + // A BARE console path (no org slug in the URL) canonicalizes onto the org | |
| 253 | + // this browser last worked in (the last-org cookie), not the org pinned in | |
| 254 | + // the session at login time — a multi-org user re-entering at `/` lands | |
| 255 | + // where they left off. Slugged URLs never enter here (the URL names its | |
| 256 | + // own scope), so this costs nothing on the steady state; the not-found | |
| 257 | + // contract is untouched because an unknown-but-valid slug in the URL reads | |
| 258 | + // as slugged, not bare. When the cookie matches the session's own org (the | |
| 259 | + // overwhelmingly common single-org case) the client-side OrgSlugGate | |
| 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 | 288 | const { hint, mint } = await resolveAuthHint(session, cookieHeader); |
| 239 | − const result = await next({ | |
| 240 | − context: { authHint: hint, origin: browserOriginFromRequest(request) }, | |
| 241 | − }); | |
| 289 | + const result = await next(); | |
| 242 | 290 | if (!mint && !session.refreshedSession) return result; |
| 243 | 291 | |
| 244 | 292 | const response = new Response(result.response.body, result.response); |
| @@ -1,10 +1,103 @@ | ||
| 1 | 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 | 26 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( |
| 4 | 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 | 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 | 102 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( |
| 10 | 103 | "WorkOSError", |
| @@ -22,11 +22,13 @@ import { env } from "cloudflare:workers"; · 21 unmodified lines | ||
| 22 | 22 | import { WorkOSError } from "./errors"; |
| 23 | 23 | import { WorkOSClient } from "./workos"; |
| 24 | 24 | import { AutumnService } from "../extensions/billing/service"; |
| 25 | +import { captureCauseEffect } from "../observability"; | |
| 25 | 26 | import { |
| 26 | 27 | hasPaidOrganizationSubscription, |
| 27 | 28 | isOverFreeOrganizationLimit, |
| 28 | 29 | shouldApplyFreeOrganizationLimit, |
| 29 | 30 | } from "../extensions/billing/plans"; |
| 31 | +import { LAST_ORG_COOKIE } from "./last-org-cookie"; | |
| 30 | 32 | import { |
| 31 | 33 | ORG_SELECTOR_HEADER, |
| 32 | 34 | authorizeOrganization, |
| @@ -143,8 +145,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines | ||
| 143 | 145 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); |
| 144 | 146 | |
| 145 | 147 | // --------------------------------------------------------------------------- |
| 146 | −// Single non-protected API surface — public (login/callback) + session | |
| 147 | −// (me/logout/organizations/switch-organization). The session group has SessionAuth on it. | |
| 148 | +// Single non-protected API surface — public (login/callback/logout) + session | |
| 149 | +// (me/organizations/switch-organization). The session group has SessionAuth on it. | |
| 148 | 150 | // --------------------------------------------------------------------------- |
| 149 | 151 | |
| 150 | 152 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); |
| @@ -201,7 +203,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines | ||
| 201 | 203 | const result = yield* workos.authenticateWithCode(query.code); |
| 202 | 204 | |
| 203 | 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 | 208 | let sealedSession = result.sealedSession; |
| 207 | 209 | |
| @@ -218,11 +220,25 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines | ||
| 218 | 220 | : null; |
| 219 | 221 | |
| 220 | 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 | |
| 222 | − // the first active membership for org-less sessions. Pending | |
| 223 | − // memberships are skipped because refreshing into one 400s and would | |
| 224 | − // bypass invite consent. | |
| 225 | − let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null; | |
| 223 | + // is bare, or not an org route, prefer the org this browser last | |
| 224 | + // worked in (the last-org cookie — it outlives the session precisely | |
| 225 | + // so a fresh login lands where the user left off), then WorkOS's | |
| 226 | + // org, then the first active membership for org-less sessions. | |
| 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 | 242 | if (!targetOrganizationId && !requestedOrgSelector) { |
| 227 | 243 | const memberships = yield* workos.listUserMemberships(result.user.id); |
| 228 | 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 | 314 | // CLI device-login discovery. The WorkOS device endpoints live on the |
| 263 | 315 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, |
| 264 | 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 | 359 | .handle("organizations", () => |
| 319 | 360 | Effect.gen(function* () { |
| 320 | 361 | const workos = yield* WorkOSClient; |
| @@ -375,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 54 unmodified lines | ||
| 375 | 416 | ), |
| 376 | 417 | { concurrency: 3 }, |
| 377 | 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 | 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 | 430 | const org = yield* workos.createOrganization(name); |
| 388 | 431 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 389 | 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 | 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 | 455 | // Try to attach the new org to the current session. This can fail |
| 395 | 456 | // (or silently return a session still scoped to the old org) when |
| 396 | 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 | 503 | // The typed confirmation must match the org's current name — the same |
| 443 | 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 | 506 | if (!org || payload.confirmName.trim() !== org.name.trim()) { |
| 446 | 507 | return yield* new OrganizationDeletionForbidden(); |
| 447 | 508 | } |
| @@ -459,7 +520,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 11 unmodified lines | ||
| 459 | 520 | // everyone (unreachable) but its secrets/tenant rows linger orphaned — |
| 460 | 521 | // alert loudly so that window gets swept, then surface the failure. |
| 461 | 522 | yield* users |
| 462 | − .use((s) => s.deleteOrganizationCascade(organizationId)) | |
| 523 | + .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) | |
| 463 | 524 | .pipe( |
| 464 | 525 | Effect.tapError((error) => |
| 465 | 526 | Effect.logError( |
| @@ -475,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines | ||
| 475 | 536 | yield* autumn |
| 476 | 537 | .use((client) => client.customers.delete({ customerId: organizationId })) |
| 477 | 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 | 542 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { |
| 480 | 543 | organizationId, |
| 481 | 544 | error, |
| @@ -560,7 +623,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 78 unmodified lines | ||
| 560 | 623 | // Mirror the org locally so domain tables can FK against it; the |
| 561 | 624 | // upsert mints the slug at insert — no separate heal step. |
| 562 | 625 | const org = yield* workos.getOrganization(invitation.organizationId); |
| 563 | − const mirrored = yield* users.use((s) => | |
| 626 | + const mirrored = yield* users.use("upsertOrganization", (s) => | |
| 564 | 627 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 565 | 628 | ); |
| 566 | 629 | |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type KeyLike, |
| 10 | 10 | } from "jose"; |
| 11 | 11 | |
| 12 | −import { createCachedRemoteJWKSet } from "./jwks-cache"; | |
| 12 | +import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; | |
| 13 | 13 | |
| 14 | 14 | const issuer = "https://test-authkit.example.com"; |
| 15 | 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 | 102 | describe("createCachedRemoteJWKSet", () => { |
| 69 | 103 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { |
| 70 | 104 | const kp = await generateRotatableKeypair("k1"); |
| @@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines | ||
| 168 | 202 | await jwtVerify(t1, jwks, { issuer, audience }); |
| 169 | 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 | 5 | // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s |
| @@ -7,14 +7,32 @@ · 1 unmodified line | ||
| 7 | 7 | // fetches per hour because each new isolate starts cold. Production p99 for |
| 8 | 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 | |
| 13 | −// TTL (default 1 hour). | |
| 14 | −// * Single-flights concurrent fetches so a stampede of verifies during a | |
| 15 | −// cache miss only fires one upstream request. | |
| 16 | −// * Force-refreshes once when verification fails with a cached key, so | |
| 17 | −// genuine key rotation isn't blocked by the TTL. | |
| 18 | +// So this module layers three caches, fastest first: | |
| 19 | +// | |
| 20 | +// 1. Module-scope memory — free, but dies with the isolate. | |
| 21 | +// 2. A cross-isolate store (the Workers Cache API by default) — colo-local, | |
| 22 | +// survives isolate recycling, so a cold isolate reads keys in ~1ms | |
| 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 | 37 | // The returned function is a `JWTVerifyGetKey` and slots directly into |
| 20 | 38 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can |
| @@ -32,6 +50,21 @@ import { · 11 unmodified lines | ||
| 32 | 50 | import { Schema } from "effect"; |
| 33 | 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 | 68 | export interface CachedRemoteJWKSetOptions { |
| 36 | 69 | /** |
| 37 | 70 | * How long a successful fetch is considered fresh. Defaults to 1 hour — |
| @@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line | ||
| 39 | 72 | * failure handles unscheduled rotations. |
| 40 | 73 | */ |
| 41 | 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 | 81 | /** Override the fetch implementation for tests. */ |
| 43 | 82 | readonly fetch?: typeof globalThis.fetch; |
| 44 | 83 | /** HTTP request timeout. Defaults to 5s, matching jose. */ |
| 45 | 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 | 92 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { |
| 49 | 93 | /** Drop the cached JWKS so the next call refetches. */ |
| 50 | 94 | readonly forceRefresh: () => void; |
| 51 | − /** Inspect the current cache state (testing/diagnostics). */ | |
| 52 | − readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean }; | |
| 95 | + /** | |
| 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 | 123 | const DEFAULT_TTL_MS = 60 * 60 * 1000; |
| 124 | +const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000; | |
| 56 | 125 | const DEFAULT_TIMEOUT_MS = 5000; |
| 57 | 126 | |
| 58 | 127 | const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); |
| @@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines | ||
| 73 | 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 | 165 | const fetchJwksOnce = async ( |
| 77 | 166 | url: URL, |
| 78 | 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 | 255 | * Creates a cached, single-flight, force-refreshable JWKS resolver compatible |
| 112 | 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 | 259 | export const createCachedRemoteJWKSet = ( |
| 116 | 260 | url: URL, |
| 117 | 261 | options: CachedRemoteJWKSetOptions = {}, |
| 118 | 262 | ): CachedRemoteJWKSet => { |
| 119 | 263 | const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; |
| 264 | + const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs); | |
| 120 | 265 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 266 | + const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store; | |
| 121 | 267 | // Capture the fetch impl lazily so consumers can swap globalThis.fetch |
| 122 | 268 | // (tests do this) without us snapshotting a stale reference. |
| 123 | 269 | const fetchImpl = (): typeof globalThis.fetch => |
| @@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line | ||
| 125 | 271 | |
| 126 | 272 | let entry: CacheEntry | null = null; |
| 127 | 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 | 286 | const refresh = (): Promise<CacheEntry> => { |
| 130 | 287 | if (inflight) return inflight; |
| 288 | + const startedAt = Date.now(); | |
| 289 | + fetchCount += 1; | |
| 131 | 290 | inflight = (async () => { |
| 132 | 291 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 133 | − const next: CacheEntry = { | |
| 134 | − jwks, | |
| 135 | − fetchedAt: Date.now(), | |
| 136 | − resolver: createLocalJWKSet(jwks), | |
| 137 | − }; | |
| 292 | + const next = entryFrom({ jwks, fetchedAt: Date.now() }); | |
| 138 | 293 | entry = next; |
| 294 | + if (store) { | |
| 295 | + await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt })); | |
| 296 | + } | |
| 139 | 297 | return next; |
| 140 | − })().finally(() => { | |
| 141 | − inflight = null; | |
| 142 | − }); | |
| 298 | + })() | |
| 299 | + .then( | |
| 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 | 311 | return inflight; |
| 144 | 312 | }; |
| 145 | 313 | |
| 146 | − const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | |
| 147 | − if (forceRefresh) return refresh(); | |
| 148 | − if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; | |
| 314 | + /** Fire-and-forget revalidation behind a stale hit. */ | |
| 315 | + const refreshInBackground = (): void => { | |
| 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 | 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 | 371 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| 153 | 372 | const current = await ensureFresh(false); |
| 154 | 373 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures |
| 155 | 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 | 379 | } catch (error) { |
| 158 | − // Likely cause: keys rotated upstream after our TTL window started. | |
| 159 | − // Refetch once and try again. Anything still failing bubbles up so | |
| 160 | − // jose can classify it (we do not silently swallow real failures). | |
| 380 | + // Likely cause: keys rotated upstream after our TTL window started, or | |
| 381 | + // we answered from a stale entry. Refetch once and try again. Anything | |
| 382 | + // still failing bubbles up so jose can classify it (we do not silently | |
| 383 | + // swallow real failures). | |
| 161 | 384 | if (!isJwksNoMatchingKey(error)) { |
| 162 | 385 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection |
| 163 | 386 | throw error; |
| @@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines | ||
| 177 | 400 | value: () => ({ |
| 178 | 401 | fetchedAt: entry?.fetchedAt ?? null, |
| 179 | 402 | hasJwks: entry !== null, |
| 403 | + fetchCount, | |
| 404 | + fetchFailureCount, | |
| 405 | + blockingFetchCount, | |
| 406 | + storeHitCount, | |
| 407 | + lastFetchDurationMs, | |
| 408 | + lastStoreReadMs, | |
| 409 | + lastResolveMs, | |
| 180 | 410 | }), |
| 181 | 411 | }); |
| 182 | 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 | 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 8 | 8 | |
| 9 | 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 — | |
| 11 | −// with the session org as the fallback for non-console callers, and live | |
| 12 | −// membership re-checked either way. This is what makes two browser tabs on | |
| 10 | +// `x-executor-organization` selector header) — NEVER the session's stored org. | |
| 11 | +// The sealed cookie's org is a browser-global pinned to whichever org WorkOS | |
| 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 | 15 | // different orgs independent. |
| 14 | 16 | |
| 15 | 17 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); |
| @@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines | ||
| 27 | 29 | listUserKeys: () => Effect.succeed([]), |
| 28 | 30 | createUserKey: () => Effect.die("not used"), |
| 29 | 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 | 37 | const stubWorkOS = Layer.succeed( |
| @@ -59,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 26 unmodified lines | ||
| 59 | 64 | ); |
| 60 | 65 | |
| 61 | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 62 | − use: (fn) => | |
| 67 | + use: (_op, fn) => | |
| 63 | 68 | Effect.promise(() => |
| 64 | 69 | fn({ |
| 65 | 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 | 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 | 104 | Effect.gen(function* () { |
| 100 | − const principal = yield* run({ cookie: "wos-session=x" }); | |
| 101 | − expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG); | |
| 105 | + // No fallback to the session org: the cookie's org is browser-global | |
| 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 | 38 | export const resolveOrganization = (organizationId: string) => |
| 39 | 39 | Effect.gen(function* () { |
| 40 | 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 | 42 | if (existing) return existing; |
| 43 | 43 | |
| 44 | 44 | const workos = yield* WorkOSClient; |
| 45 | 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 | 127 | return yield* authorizeOrganization(userId, selector); |
| 126 | 128 | } |
| 127 | 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 | 131 | if (!org) return null; |
| 130 | 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 | 16 | // - invalid api key -> Unauthorized 401 invalid_api_key |
| 17 | 17 | // - api-key org not authorized -> NoOrganization 403 no_organization |
| 18 | 18 | // - no/invalid session -> NoOrganization 403 no_organization |
| 19 | +// - session without org header -> NoOrganization 403 no_organization (fail closed) | |
| 19 | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 20 | 21 | // - no auth header -> falls through to the sealed-session path |
| 21 | 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are |
| @@ -37,7 +38,13 @@ import { · 15 unmodified lines | ||
| 37 | 38 | Unauthorized, |
| 38 | 39 | Unavailable, |
| 39 | 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 | 49 | import { ApiKeyService } from "./api-keys"; |
| 43 | 50 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; |
| @@ -46,6 +53,7 @@ import { · 2 unmodified lines | ||
| 46 | 53 | authorizeOrganization, |
| 47 | 54 | authorizeOrganizationSelector, |
| 48 | 55 | orgSelectorFromRequest, |
| 56 | + resolveOrganization, | |
| 49 | 57 | } from "./organization"; |
| 50 | 58 | import { UserStoreService } from "./context"; |
| 51 | 59 | import { sealedSessionDisplayName } from "./middleware"; |
| @@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines | ||
| 86 | 94 | code: "no_organization", |
| 87 | 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 | 101 | const INVALID_ACCESS_TOKEN = { |
| 90 | 102 | code: "invalid_access_token", |
| 91 | 103 | message: "Invalid or expired access token", |
| @@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines | ||
| 98 | 110 | code: "no_organization", |
| 99 | 111 | message: "No organization in access token", |
| 100 | 112 | }; |
| 101 | − | |
| 102 | 113 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access |
| 103 | 114 | // token from the CLI device-login); anything else is treated as an API key. |
| 104 | 115 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). |
| @@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines | ||
| 134 | 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); |
| 135 | 146 | |
| 136 | 147 | return { |
| 148 | + kind: "member", | |
| 137 | 149 | accountId: verified.accountId, |
| 138 | 150 | organizationId: org.id, |
| 139 | 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 | |
| 150 | − * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS | |
| 151 | − * API key. Returns `null` when there is no `Authorization` header, so the | |
| 152 | − * caller falls through to the sealed-session path. (Kept the historical name, | |
| 153 | − * the re-export and resolver tests reference it.) | |
| 161 | + * What an ORG-level API key resolves to: the platform view. Deliberately NOT a | |
| 162 | + * `Principal` — a `Principal` names an acting member (`accountId: string`), and | |
| 163 | + * an org key has none. Keeping it a separate shape means no product code path | |
| 164 | + * can accidentally treat it as a member, and the executor built from it binds | |
| 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 | 204 | Effect.gen(function* () { |
| 157 | 205 | const authHeader = request.headers.get("authorization"); |
| 158 | 206 | if (!authHeader) return null; |
| @@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines | ||
| 167 | 215 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); |
| 168 | 216 | |
| 169 | 217 | const apiKeys = yield* ApiKeyService; |
| 170 | − const principal = yield* apiKeys | |
| 218 | + const owner = yield* apiKeys | |
| 171 | 219 | .validate(value) |
| 172 | 220 | .pipe( |
| 173 | 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 | 244 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 182 | 245 | |
| 183 | 246 | return { |
| 184 | − accountId: principal.accountId, | |
| 247 | + kind: "member", | |
| 248 | + accountId: owner.accountId, | |
| 185 | 249 | organizationId: org.id, |
| 186 | 250 | organizationName: org.name, |
| 187 | 251 | organizationSlug: org.slug, |
| @@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines | ||
| 192 | 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 | 293 | export const resolveSessionPrincipal = (request: Request) => |
| 196 | 294 | Effect.gen(function* () { |
| 197 | 295 | const workos = yield* WorkOSClient; |
| @@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line | ||
| 199 | 297 | if (!session) { |
| 200 | 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 | |
| 203 | − // session's own org is the fallback for non-console callers. Membership is | |
| 204 | − // re-checked live either way — the header is a selector, not a trust | |
| 205 | − // boundary (see organization.ts). | |
| 206 | − const selector = orgSelectorFromRequest(request) ?? session.organizationId; | |
| 300 | + // The console URL's org is the scope authority, sent as a header on every | |
| 301 | + // request. FAIL CLOSED when it's missing: the sealed cookie's org is | |
| 302 | + // browser-global and pinned to whichever org WorkOS last touched, so | |
| 303 | + // falling back to it silently serves ANOTHER org's data to a multi-org | |
| 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 | 314 | if (!selector) { |
| 208 | − return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | |
| 315 | + return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR); | |
| 209 | 316 | } |
| 210 | 317 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 211 | 318 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 212 | 319 | return { |
| 320 | + kind: "member", | |
| 213 | 321 | accountId: session.userId, |
| 214 | 322 | organizationId: org.id, |
| 215 | 323 | organizationName: org.name, |
| @@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines | ||
| 236 | 344 | request: Request, |
| 237 | 345 | jwt: JwtBearerConfig | null = null, |
| 238 | 346 | ): Effect.Effect< |
| 239 | − Principal, | |
| 347 | + ResolvedPrincipal, | |
| 240 | 348 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 241 | 349 | WorkOSClient | ApiKeyService | UserStoreService |
| 242 | 350 | > => |
| @@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines | ||
| 317 | 425 | "service_unavailable", |
| 318 | 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 | 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 | 286 | describe("collectWorkOSList", () => { |
| 6 | 287 | it("collects memberships beyond the first WorkOS page", async () => { |
| @@ -3,13 +3,23 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | 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 | 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 | 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 | 20 | const COOKIE_NAME = "wos-session"; |
| 12 | 21 | const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters"; |
| 22 | +const WORKOS_SEAL_VERSION_DELIMITER = "~"; | |
| 13 | 23 | |
| 14 | 24 | type RawWorkOS = WorkOS & { |
| 15 | 25 | readonly get: ( |
| @@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines | ||
| 59 | 69 | |
| 60 | 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 | 305 | const completedListMetadata = { |
| 63 | 306 | before: null, |
| 64 | 307 | after: null, |
| @@ -150,14 +393,18 @@ const make = Effect.gen(function* () { · 85 unmodified lines | ||
| 150 | 393 | clientId, |
| 151 | 394 | ...workosApiUrlOptions(env.WORKOS_API_URL), |
| 152 | 395 | }); |
| 396 | + const sessionJwks = getWorkOSSessionJwks(new URL(workos.userManagement.getJwksUrl(clientId))); | |
| 153 | 397 | |
| 154 | 398 | // The public `WorkOSError` carries the upstream HTTP status when the SDK |
| 155 | 399 | // exception had one (all its typed exceptions do), so consumers can tell a |
| 156 | 400 | // definitive WorkOS denial (401/403/404 — fail closed) from a transient |
| 157 | 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 | 406 | withServiceLogging( |
| 160 | − "workos", | |
| 407 | + `workos.${op}`, | |
| 161 | 408 | workosErrorFromFailure, |
| 162 | 409 | tryPromiseService(() => fn(workos)), |
| 163 | 410 | ); |
| @@ -171,25 +418,29 @@ const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 171 | 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 | 428 | return { |
| 178 | − userId: result.user.id, | |
| 179 | − email: result.user.email, | |
| 180 | − firstName: result.user.firstName, | |
| 181 | − lastName: result.user.lastName, | |
| 182 | − avatarUrl: result.user.profilePictureUrl, | |
| 183 | − organizationId: result.organizationId, | |
| 184 | − sessionId: result.sessionId, | |
| 429 | + userId: local.session.user.id, | |
| 430 | + email: local.session.user.email, | |
| 431 | + firstName: local.session.user.firstName, | |
| 432 | + lastName: local.session.user.lastName, | |
| 433 | + avatarUrl: local.session.user.profilePictureUrl, | |
| 434 | + organizationId: local.organizationId, | |
| 435 | + sessionId: local.sessionId, | |
| 185 | 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 | 442 | // Try refreshing |
| 192 | − const refreshed = yield* use(() => session.refresh()).pipe( | |
| 443 | + const refreshed = yield* use("session.refresh", () => session.refresh()).pipe( | |
| 193 | 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 | 471 | authenticateWithCode: (code: string) => |
| 221 | − use((wos) => | |
| 472 | + use("userManagement.authenticateWithCode", (wos) => | |
| 222 | 473 | wos.userManagement.authenticateWithCode({ |
| 223 | 474 | code, |
| 224 | 475 | clientId, |
| @@ -228,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 228 | 479 | |
| 229 | 480 | /** Create a new organization in WorkOS. */ |
| 230 | 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 | 486 | /** Add a user to an organization. */ |
| 234 | 487 | createMembership: (organizationId: string, userId: string, roleSlug?: string) => |
| 235 | − use((wos) => | |
| 488 | + use("userManagement.createOrganizationMembership", (wos) => | |
| 236 | 489 | wos.userManagement.createOrganizationMembership({ |
| 237 | 490 | organizationId, |
| 238 | 491 | userId, |
| @@ -242,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 242 | 495 | |
| 243 | 496 | /** List organization memberships for a user. */ |
| 244 | 497 | listUserMemberships: (userId: string) => |
| 245 | − use(async (wos) => | |
| 498 | + use("userManagement.listOrganizationMemberships", async (wos) => | |
| 246 | 499 | collectWorkOSList( |
| 247 | 500 | await wos.userManagement.listOrganizationMemberships({ |
| 248 | 501 | userId, |
| @@ -261,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines | ||
| 261 | 514 | sessionData, |
| 262 | 515 | cookiePassword, |
| 263 | 516 | }); |
| 264 | − const refreshed = yield* use(() => | |
| 517 | + const refreshed = yield* use("session.refresh", () => | |
| 265 | 518 | session.refresh(organizationId ? { organizationId } : undefined), |
| 266 | 519 | ); |
| 267 | 520 | if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; |
| 268 | 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 | 565 | * Authenticate a sealed session string. Returns the user info plus |
| 273 | 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 | 583 | * auth/api-keys.ts. |
| 291 | 584 | */ |
| 292 | 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 | 591 | listUserApiKeys: (userId: string, organizationId: string) => |
| 296 | − use(async (wos) => { | |
| 592 | + use("userManagement.listUserApiKeys", async (wos) => { | |
| 297 | 593 | const raw = wos as RawWorkOS; |
| 298 | 594 | return collectRawWorkOSList(async (after) => { |
| 299 | 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 | 606 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => |
| 311 | − use(async (wos) => { | |
| 607 | + use("userManagement.createUserApiKey", async (wos) => { | |
| 312 | 608 | const raw = wos as RawWorkOS; |
| 313 | 609 | const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { |
| 314 | 610 | name: params.name, |
| @@ -317,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 317 | 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 | 647 | /** List organization memberships with user details. */ |
| 323 | 648 | listOrgMembers: (organizationId: string) => |
| 324 | − use(async (wos) => | |
| 649 | + use("userManagement.listOrganizationMemberships", async (wos) => | |
| 325 | 650 | collectWorkOSList( |
| 326 | 651 | await wos.userManagement.listOrganizationMemberships({ |
| 327 | 652 | organizationId, |
| @@ -332,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 332 | 657 | |
| 333 | 658 | /** Get a user's membership in an organization. */ |
| 334 | 659 | getUserOrgMembership: (organizationId: string, userId: string) => |
| 335 | − use(async (wos) => { | |
| 660 | + use("userManagement.listOrganizationMemberships", async (wos) => { | |
| 336 | 661 | const response = await wos.userManagement.listOrganizationMemberships({ |
| 337 | 662 | organizationId, |
| 338 | 663 | userId, |
| @@ -342,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 342 | 667 | }), |
| 343 | 668 | |
| 344 | 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 | 684 | /** Send an organization invitation. */ |
| 348 | 685 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => |
| 349 | − use((wos) => | |
| 686 | + use("userManagement.sendInvitation", (wos) => | |
| 350 | 687 | wos.userManagement.sendInvitation({ |
| 351 | 688 | email: params.email, |
| 352 | 689 | organizationId: params.organizationId, |
| @@ -360,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 360 | 697 | * API level, so we filter after. |
| 361 | 698 | */ |
| 362 | 699 | listPendingInvitations: (organizationId: string) => |
| 363 | − use(async (wos) => | |
| 700 | + use("userManagement.listInvitations", async (wos) => | |
| 364 | 701 | collectWorkOSList( |
| 365 | 702 | await wos.userManagement.listInvitations({ |
| 366 | 703 | organizationId, |
| @@ -375,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines | ||
| 375 | 712 | |
| 376 | 713 | /** List invitations for an email address (across all orgs). */ |
| 377 | 714 | listInvitationsByEmail: (email: string) => |
| 378 | − use(async (wos) => | |
| 715 | + use("userManagement.listInvitations", async (wos) => | |
| 379 | 716 | collectWorkOSList( |
| 380 | 717 | await wos.userManagement.listInvitations({ |
| 381 | 718 | email, |
| @@ -385,19 +722,25 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 385 | 722 | |
| 386 | 723 | /** Accept an invitation; returns the (now accepted) invitation. */ |
| 387 | 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 | 729 | /** Remove an organization membership. */ |
| 391 | 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 | 735 | /** Get the role for a membership. */ |
| 395 | 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 | 741 | /** Update a membership's role. */ |
| 399 | 742 | updateOrgMembershipRole: (membershipId: string, roleSlug: string) => |
| 400 | − use((wos) => | |
| 743 | + use("userManagement.updateOrganizationMembership", (wos) => | |
| 401 | 744 | wos.userManagement.updateOrganizationMembership(membershipId, { |
| 402 | 745 | roleSlug, |
| 403 | 746 | }), |
| @@ -405,15 +748,19 @@ const make = Effect.gen(function* () { · 1 unmodified line | ||
| 405 | 748 | |
| 406 | 749 | /** List available roles for an organization. */ |
| 407 | 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 | 755 | /** Get an organization (includes domains). */ |
| 411 | 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 | 761 | /** Update an organization. */ |
| 415 | 762 | updateOrganization: (organizationId: string, name: string) => |
| 416 | − use((wos) => | |
| 763 | + use("organizations.updateOrganization", (wos) => | |
| 417 | 764 | wos.organizations.updateOrganization({ |
| 418 | 765 | organization: organizationId, |
| 419 | 766 | name, |
| @@ -425,11 +772,13 @@ const make = Effect.gen(function* () { · 5 unmodified lines | ||
| 425 | 772 | * invitations, and domains go with it, so every member loses access. |
| 426 | 773 | */ |
| 427 | 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 | 779 | /** Generate an Admin Portal link for domain verification. */ |
| 431 | 780 | generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => |
| 432 | − use((wos) => | |
| 781 | + use("portal.generateLink", (wos) => | |
| 433 | 782 | wos.portal.generateLink({ |
| 434 | 783 | organization: organizationId, |
| 435 | 784 | intent: GeneratePortalLinkIntent.DomainVerification, |
| @@ -439,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 439 | 788 | |
| 440 | 789 | /** Get a domain by ID. */ |
| 441 | 790 | getOrganizationDomain: (domainId: string) => |
| 442 | − use((wos) => wos.organizationDomains.get(domainId)), | |
| 791 | + use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)), | |
| 443 | 792 | |
| 444 | 793 | /** Delete a domain claim. */ |
| 445 | 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 | 801 | export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()( |
| 453 | 802 | "@executor-js/cloud/WorkOSClient", |
| 454 | 803 | ) { |
| 455 | − static Default = Layer.effect(this)(make).pipe( | |
| 456 | − Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), | |
| 457 | − ); | |
| 804 | + // Deliberately unspanned: client construction is synchronous and ran per | |
| 805 | + // layer build, which produced one of the highest-volume zero-duration span | |
| 806 | + // names in the whole trace corpus while telling us nothing. | |
| 807 | + static Default = Layer.effect(this)(make); | |
| 458 | 808 | } |
| 459 | 809 | |
| 460 | 810 | // The boot-scoped WorkOS client root — the one neutral service the stateless |
| @@ -2,7 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | // merge of cloud + executor schemas. If ANY call site builds its own |
| 3 | 3 | // `{ schema }` without spreading both, `db._.fullSchema` comes back |
| 4 | 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 | 7 | // We hit this in prod (MCP endpoint) when `mcp-session.ts` built |
| 8 | 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 | 60 | // any of these go missing the drizzle adapter's `getTable` lookup throws. |
| 61 | 61 | it("includes scope-sharded executor tables", () => { |
| 62 | 62 | for (const key of Object.keys(executorSchema)) { |
| @@ -24,7 +24,7 @@ import * as executorSchema from "./executor-schema"; · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | // Exported so every drizzle() call in the cloud app shares one schema |
| 26 | 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 | 28 | // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. |
| 29 | 29 | export const combinedSchema = { ...cloudSchema, ...executorSchema }; |
| 30 | 30 | |
| @@ -21,6 +21,8 @@ · 20 unmodified lines | ||
| 21 | 21 | // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through |
| 22 | 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 | 26 | import { describe, expect, it } from "@effect/vitest"; |
| 25 | 27 | import { PGlite } from "@electric-sql/pglite"; |
| 26 | 28 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| @@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines | ||
| 30 | 32 | const CLIENTS = 6; |
| 31 | 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 | 79 | describe("dev-db PGlite socket under concurrent connections", () => { |
| 34 | 80 | it( |
| 35 | 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 | 94 | const errors: string[] = []; |
| 49 | 95 | |
| 50 | 96 | const worker = async (id: number) => { |
| 51 | − const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { | |
| 52 | − max: 1, | |
| 53 | − idle_timeout: 0, | |
| 54 | − connect_timeout: 10, | |
| 55 | − fetch_types: false, | |
| 56 | − prepare: true, | |
| 57 | − onnotice: () => undefined, | |
| 58 | − }); | |
| 97 | + const sql = makeClient(PORT, 10); | |
| 59 | 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 | 99 | try { |
| 61 | 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 | 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 | 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 | 52 | export const connection = pgTable( |
| 37 | 53 | "connection", |
| 38 | 54 | { |
| @@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines | ||
| 86 | 102 | origin_kind: text("origin_kind"), |
| 87 | 103 | origin_integration: text("origin_integration"), |
| 88 | 104 | origin_issuer: text("origin_issuer"), |
| 105 | + origin_redirect_uri: text("origin_redirect_uri"), | |
| 89 | 106 | created_at: timestamp("created_at").notNull(), |
| 90 | 107 | row_id: varchar("row_id", { length: 255 }) |
| 91 | 108 | .primaryKey() |
| @@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines | ||
| 164 | 181 | integration: varchar("integration", { length: 255 }).notNull(), |
| 165 | 182 | connection: varchar("connection", { length: 255 }).notNull(), |
| 166 | 183 | plugin_id: text("plugin_id").notNull(), |
| 167 | − name: varchar("name", { length: 255 }).notNull(), | |
| 184 | + name: text("name").notNull(), | |
| 168 | 185 | schema: json("schema").notNull(), |
| 169 | 186 | created_at: timestamp("created_at").notNull(), |
| 170 | 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 | 251 | export const plugin_storage = pgTable( |
| 213 | 252 | "plugin_storage", |
| 214 | 253 | { |
| @@ -11,16 +11,24 @@ · 10 unmodified lines | ||
| 11 | 11 | // - a second org's data is completely untouched |
| 12 | 12 | // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must |
| 13 | 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 | 20 | import { describe, it, expect } from "@effect/vitest"; |
| 16 | 21 | import { Effect } from "effect"; |
| 17 | −import { eq, inArray } from "drizzle-orm"; | |
| 22 | +import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm"; | |
| 18 | 23 | |
| 19 | 24 | import { DbService } from "./db"; |
| 20 | 25 | import type { DrizzleDb } from "./db"; |
| 21 | 26 | import { makeUserStore } from "../auth/user-store"; |
| 27 | +import * as cloudSchema from "./schema"; | |
| 28 | +import * as executorSchema from "./executor-schema"; | |
| 22 | 29 | import { memberships, accounts } from "./schema"; |
| 23 | 30 | import { |
| 31 | + artifact, | |
| 24 | 32 | blob, |
| 25 | 33 | connection, |
| 26 | 34 | definition, |
| @@ -28,6 +36,7 @@ import { · 1 unmodified line | ||
| 28 | 36 | oauth_client, |
| 29 | 37 | oauth_session, |
| 30 | 38 | plugin_storage, |
| 39 | + subject, | |
| 31 | 40 | tool, |
| 32 | 41 | tool_policy, |
| 33 | 42 | } from "./executor-schema"; |
| @@ -131,6 +140,24 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { · 97 unmodified lines | ||
| 131 | 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 | 161 | const orgNs = `o:${tenant}/plugin`; |
| 135 | 162 | const userNs = `u:${tenant}:subject/plugin`; |
| 136 | 163 | await db.insert(blob).values({ |
| @@ -156,8 +183,26 @@ const TENANT_TABLES = [ · 19 unmodified lines | ||
| 156 | 183 | definition, |
| 157 | 184 | tool_policy, |
| 158 | 185 | plugin_storage, |
| 186 | + subject, | |
| 187 | + artifact, | |
| 159 | 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 | 206 | const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => { |
| 162 | 207 | let total = 0; |
| 163 | 208 | for (const table of TENANT_TABLES) { |
| @@ -249,3 +294,70 @@ describe("purgeOrganizationData", () => { · 85 unmodified lines | ||
| 249 | 294 | ); |
| 250 | 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 | 16 | import type { DrizzleDb } from "./db"; |
| 17 | 17 | import { organizations } from "./schema"; |
| 18 | 18 | import { |
| 19 | + artifact, | |
| 19 | 20 | blob, |
| 20 | 21 | connection, |
| 21 | 22 | definition, |
| @@ -23,6 +24,7 @@ import { · 1 unmodified line | ||
| 23 | 24 | oauth_client, |
| 24 | 25 | oauth_session, |
| 25 | 26 | plugin_storage, |
| 27 | + subject, | |
| 26 | 28 | tool, |
| 27 | 29 | tool_policy, |
| 28 | 30 | } from "./executor-schema"; |
| @@ -48,6 +50,8 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr · 19 unmodified lines | ||
| 48 | 50 | await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); |
| 49 | 51 | await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); |
| 50 | 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 | 56 | // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by |
| 53 | 57 | // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>` |
| @@ -6,43 +6,23 @@ · 5 unmodified lines | ||
| 6 | 6 | // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap |
| 7 | 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 | |
| 10 | −// the prod-only `env.MARKETING` service binding), this is a plain external | |
| 11 | −// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in | |
| 12 | −// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), | |
| 13 | −// so this never shadows an Effect-served route. | |
| 9 | +// The matching, upstream construction, and client span live in `./passthrough`, | |
| 10 | +// which server.ts dispatches BEFORE Start loads: forwarding a docs page must | |
| 11 | +// not pay for the whole server graph. This middleware stays registered so hosts | |
| 12 | +// that reach Start by another entry (local dev) keep identical behavior; in the | |
| 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 | 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 | − 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 | −}; | |
| 21 | +export { buildDocsUpstream, isDocsPath } from "./passthrough"; | |
| 42 | 22 | |
| 43 | 23 | export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( |
| 44 | 24 | ({ pathname, request, next }) => { |
| 45 | 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 | |
| 3 | −// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch | |
| 4 | −// the Effect app layer; they proxy or tunnel to external services (the | |
| 5 | −// marketing worker, Sentry, PostHog, Mintlify docs). | |
| 2 | +// Edge concerns — request middleware that runs before the app's own mcp + api | |
| 3 | +// dispatch. These proxy or tunnel to external services without touching the | |
| 4 | +// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a | |
| 5 | +// public page never loads the TanStack Start graph. | |
| 6 | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | |
| 8 | −export { marketingMiddleware } from "./marketing"; | |
| 9 | 8 | export { sentryTunnelMiddleware } from "./sentry-tunnel"; |
| 10 | 9 | export { posthogProxyMiddleware } from "./posthog"; |
| 11 | 10 | export { docsProxyMiddleware } from "./docs"; |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { isMarketingPath } from "./marketing"; | |
| 3 | +import { isMarketingPath, marketingProxyRequest } from "./marketing"; | |
| 4 | 4 | |
| 5 | 5 | // On executor.sh the marketing middleware proxies an allow-list of paths to the |
| 6 | 6 | // `executor-marketing` worker; everything else falls through to the auth-gated |
| @@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines | ||
| 12 | 12 | "/home", |
| 13 | 13 | "/privacy", |
| 14 | 14 | "/terms", |
| 15 | + "/about-executor", | |
| 16 | + "/google-oauth", | |
| 17 | + "/google-workspace", | |
| 15 | 18 | "/blog", |
| 16 | 19 | "/blog/", |
| 17 | 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 | 4 | // On the production domain (`executor.sh`), marketing paths and the |
| 5 | 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 | |
| 7 | −// unauthenticated visits fall through to the cloud app's routes (the sign-in | |
| 8 | −// page). | |
| 6 | +// worker. This module deliberately has no TanStack Start or cloud application | |
| 7 | +// imports: the Worker entry calls it before loading the Start server graph. | |
| 9 | 8 | // --------------------------------------------------------------------------- |
| 10 | 9 | |
| 11 | −import { env } from "cloudflare:workers"; | |
| 12 | −import { createMiddleware } from "@tanstack/react-start"; | |
| 13 | − | |
| 14 | 10 | import { parseCookie } from "../auth/cookies"; |
| 15 | 11 | |
| 16 | 12 | const MARKETING_PATHS = [ |
| @@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line | ||
| 18 | 14 | "/setup", |
| 19 | 15 | "/privacy", |
| 20 | 16 | "/terms", |
| 17 | + "/about-executor", | |
| 18 | + "/google-oauth", | |
| 19 | + "/google-workspace", | |
| 21 | 20 | "/blog", |
| 22 | 21 | "/changelog", |
| 23 | 22 | "/changelog.json", |
| @@ -29,33 +28,25 @@ const MARKETING_PATHS = [ · 5 unmodified lines | ||
| 29 | 28 | "/pattern-graph-paper.svg", |
| 30 | 29 | ]; |
| 31 | 30 | |
| 32 | −export const isMarketingPath = (pathname: string) => | |
| 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(); | |
| 31 | +const SESSION_COOKIE = "wos-session"; | |
| 44 | 32 | |
| 45 | − const shouldProxyToMarketing = | |
| 46 | − isMarketingPath(pathname) || | |
| 47 | − (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); | |
| 48 | − | |
| 49 | − if (!shouldProxyToMarketing) return next(); | |
| 50 | − | |
| 51 | − const marketing = getMarketingWorker(); | |
| 52 | − if (!marketing) return next(); | |
| 33 | +/** Whether an exact pathname belongs to the public marketing worker. */ | |
| 34 | +export const isMarketingPath = (pathname: string): boolean => | |
| 35 | + MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); | |
| 53 | 36 | |
| 54 | − const url = new URL(request.url); | |
| 55 | − // Rewrite /home to / so marketing worker serves its homepage | |
| 56 | − if (pathname === "/home") { | |
| 57 | − url.pathname = "/"; | |
| 58 | − } | |
| 59 | − return marketing.fetch(new Request(url, request)); | |
| 60 | − }, | |
| 61 | −); | |
| 37 | +/** | |
| 38 | + * Project a production request onto the marketing service-binding request. | |
| 39 | + * Returns `null` when the cloud application owns the request instead. | |
| 40 | + */ | |
| 41 | +export const marketingProxyRequest = (request: Request): Request | null => { | |
| 42 | + const url = new URL(request.url); | |
| 43 | + if (url.hostname !== "executor.sh") return null; | |
| 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 | 3 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps |
| 4 | 4 | // events flowing past adblockers that match *.posthog.com. See |
| 5 | 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 | 13 | import { createMiddleware } from "@tanstack/react-start"; |
| 9 | 14 | |
| 10 | −const POSTHOG_INGEST_HOST = "us.i.posthog.com"; | |
| 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 | −)}`; | |
| 15 | +import { buildPosthogUpstream, isPosthogPath } from "./passthrough"; | |
| 16 | 16 | |
| 17 | 17 | export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( |
| 18 | 18 | ({ pathname, request, next }) => { |
| 19 | − if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { | |
| 20 | − return next(); | |
| 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); | |
| 19 | + if (!isPosthogPath(pathname)) return next(); | |
| 20 | + return fetch(buildPosthogUpstream(request, pathname)); | |
| 34 | 21 | }, |
| 35 | 22 | ); |
| @@ -100,10 +100,17 @@ export const withPreExecutionGate = <E extends Cause.YieldableError>( · 99 unmodified lines | ||
| 100 | 100 | ), |
| 101 | 101 | // resume is never gated: paused executions must be able to complete. |
| 102 | 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 | 107 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), |
| 104 | 108 | pausedExecutionCount: () => engine.pausedExecutionCount(), |
| 105 | 109 | hasPausedExecutions: () => engine.hasPausedExecutions(), |
| 106 | 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 | 4 | // The balance gate (execution-gate.ts) depends on Autumn and fails open, so a |
| 5 | 5 | // billing outage plus runaway automation could still run unbounded executions. |
| @@ -8,13 +8,26 @@ · 2 unmodified lines | ||
| 8 | 8 | // each MCP session lives in its own DO instance, so an in-memory counter |
| 9 | 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 | 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 | 29 | import { DurableObject, env } from "cloudflare:workers"; |
| 17 | −import { Data, Effect } from "effect"; | |
| 30 | +import { Data, Effect, Predicate } from "effect"; | |
| 18 | 31 | import type * as Cause from "effect/Cause"; |
| 19 | 32 | |
| 20 | 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 | 39 | // Fixed window: all executions in the same clock hour share one counter. |
| 27 | 40 | export const RATE_LIMIT_WINDOW_MS = 3_600_000; |
| 28 | −// Calibration: the heaviest legitimate org runs ~1.1k executions per MONTH, | |
| 29 | −// so 1000 per HOUR is far above any human-driven usage and only trips on | |
| 30 | −// runaway automation (the incident this backstops: ~18k in 30 days would | |
| 31 | −// still pass, which is fine — that class of overrun is the balance gate's | |
| 32 | −// job; this catches tight loops). | |
| 41 | +// The cap for organizations WITHOUT a paid subscription. | |
| 42 | +// | |
| 43 | +// The original calibration ("the heaviest legitimate org runs ~1.1k executions | |
| 44 | +// per MONTH, so 1000 per HOUR is far above any human-driven usage") went stale | |
| 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 | 50 | export const EXECUTIONS_PER_ORG_PER_HOUR = 1000; |
| 34 | 51 | // Counter DO slower than this => fail open rather than stall executions. |
| 35 | 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 | 61 | // The DO purges its storage this long after the last increment, so idle orgs |
| 37 | 62 | // cost nothing. Two windows: long enough that an active window never purges. |
| 38 | 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 | 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 | 154 | // Counter Durable Object — one instance per organization (idFromName(orgId)). |
| 56 | 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 | 167 | export class ExecutionRateLimiterDO extends DurableObject { |
| 69 | 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 | 176 | constructor(ctx: DurableObjectState, doEnv: Env) { |
| 72 | 177 | super(ctx, doEnv); |
| @@ -80,11 +185,21 @@ export class ExecutionRateLimiterDO extends DurableObject { · 7 unmodified lines | ||
| 80 | 185 | const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY); |
| 81 | 186 | const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; |
| 82 | 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 | 198 | return count; |
| 85 | 199 | } |
| 86 | 200 | |
| 87 | 201 | async alarm(): Promise<void> { |
| 202 | + this.purgeArmedForWindow = null; | |
| 88 | 203 | await this.counterStorage.deleteAll(); |
| 89 | 204 | } |
| 90 | 205 | } |
| @@ -93,11 +208,17 @@ export class ExecutionRateLimiterDO extends DurableObject { · 2 unmodified lines | ||
| 93 | 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 | 218 | export type RateLimitIncrement = ( |
| 98 | 219 | organizationId: string, |
| 99 | 220 | windowId: number, |
| 100 | −) => Effect.Effect<number, unknown>; | |
| 221 | +) => Effect.Effect<number, RateLimitCounterError>; | |
| 101 | 222 | |
| 102 | 223 | export type ExecutionRateLimiter = { |
| 103 | 224 | readonly decorate: <E extends Cause.YieldableError>( |
| @@ -119,12 +240,76 @@ export const makeExecutionRateLimiter = ( · 15 unmodified lines | ||
| 119 | 240 | readonly windowMs?: number; |
| 120 | 241 | readonly timeoutMs?: number; |
| 121 | 242 | readonly now?: () => number; |
| 243 | + readonly isExempt?: ExecutionRateLimitExemption; | |
| 244 | + readonly exemptionTtlMs?: number; | |
| 122 | 245 | }, |
| 123 | 246 | ): ExecutionRateLimiter => { |
| 124 | 247 | const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR; |
| 125 | 248 | const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS; |
| 126 | 249 | const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS; |
| 127 | 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 | 314 | const decide = (organizationId: string): Effect.Effect<GateDecision> => |
| 130 | 315 | Effect.suspend(() => { |
| @@ -134,29 +319,86 @@ export const makeExecutionRateLimiter = ( · 3 unmodified lines | ||
| 134 | 319 | duration: `${timeoutMs} millis`, |
| 135 | 320 | orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })), |
| 136 | 321 | }), |
| 137 | − Effect.map( | |
| 138 | − (count): GateDecision => | |
| 139 | − count > limit | |
| 140 | − ? { | |
| 141 | − blocked: true, | |
| 142 | − error: new ExecutionRateLimitExceededError({ | |
| 143 | − organizationId, | |
| 144 | − message: RATE_LIMIT_BLOCKED_MESSAGE, | |
| 145 | − }), | |
| 146 | − } | |
| 147 | − : { blocked: false }, | |
| 148 | − ), | |
| 322 | + Effect.flatMap((count): Effect.Effect<GateDecision> => { | |
| 323 | + // Under the cap: no exemption lookup, no extra I/O. | |
| 324 | + if (count <= limit) | |
| 325 | + return Effect.as( | |
| 326 | + Effect.annotateCurrentSpan({ | |
| 327 | + "rate_limit.count": count, | |
| 328 | + "rate_limit.blocked": false, | |
| 329 | + "rate_limit.check.failed_open": false, | |
| 330 | + }), | |
| 331 | + { blocked: false }, | |
| 332 | + ); | |
| 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 | 370 | // FAIL OPEN: the backstop must never block executions because its |
| 150 | 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 | 386 | Effect.catch((error: unknown) => |
| 152 | − Effect.gen(function* () { | |
| 153 | − yield* Effect.sync(() => { | |
| 154 | − console.warn("[rate-limit] execution rate limit check failed open:", error); | |
| 155 | − }); | |
| 156 | − yield* captureCauseEffect(error); | |
| 157 | − return { blocked: false } as const satisfies GateDecision; | |
| 387 | + failOpen(error, { | |
| 388 | + errorTag: Predicate.isTagged(error, "RateLimitCounterError") | |
| 389 | + ? "RateLimitCounterError" | |
| 390 | + : "unknown", | |
| 391 | + timedOut: false, | |
| 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 | 427 | * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO. |
| 186 | 428 | * When the binding is absent (unit-test workers, older local setups) the |
| 187 | 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 | 437 | const limit = resolveRateLimit(); |
| 191 | 438 | const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace }) |
| 192 | 439 | .EXECUTION_RATE_LIMITER; |
| @@ -196,17 +443,53 @@ export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { · 3 unmodified lines | ||
| 196 | 443 | ); |
| 197 | 444 | return makeExecutionRateLimiter(() => Effect.succeed(0)); |
| 198 | 445 | } |
| 199 | − return makeExecutionRateLimiter( | |
| 200 | − (organizationId, windowId) => | |
| 201 | − Effect.tryPromise(() => { | |
| 446 | + return makeExecutionRateLimiter(counterIncrement(namespace), { | |
| 447 | + limit, | |
| 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 | 467 | const stub = namespace.get( |
| 203 | 468 | namespace.idFromName(organizationId), |
| 204 | 469 | ) as ExecutionRateLimiterStub; |
| 205 | 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 }, | |
| 208 | − ); | |
| 209 | −}; | |
| 492 | + ); | |
| 210 | 493 | |
| 211 | 494 | /** |
| 212 | 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 | 503 | const parsed = Number.parseInt(raw, 10); |
| 221 | 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 | 26 | } from "@executor-js/api/server"; |
| 27 | 27 | |
| 28 | 28 | import { AutumnService } from "../extensions/billing/service"; |
| 29 | +import { hasPaidOrganizationSubscription } from "../extensions/billing/plans"; | |
| 29 | 30 | import type { DbService } from "../db/db"; |
| 30 | 31 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; |
| 31 | 32 | import { makeExecutionLimitGate } from "./execution-gate"; |
| @@ -34,7 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 2 unmodified lines | ||
| 34 | 35 | |
| 35 | 36 | // Usage-metering decorator bound to the billing service, plus the two |
| 36 | 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 | 40 | // 2. execution balance gate (Autumn check, cached 60s, fails open) |
| 39 | 41 | // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing |
| 40 | 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 | 50 | const balanceGate = makeExecutionLimitGate((organizationId) => |
| 49 | 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 | 65 | return { |
| 53 | 66 | decorate: (engine, identity: EngineStackIdentity) => |
| 54 | 67 | rateLimiter.decorate( |
| @@ -42,9 +42,17 @@ import { · 41 unmodified lines | ||
| 42 | 42 | PluginsProvider, |
| 43 | 43 | collectTables, |
| 44 | 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 | 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 | 54 | import executorConfig from "../../executor.config"; |
| 55 | +import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; | |
| 48 | 56 | import { DbService } from "../db/db"; |
| 49 | 57 | import { cloudDbProviderLayer } from "../db/fuma"; |
| 50 | 58 | |
| @@ -54,11 +62,20 @@ export { makeExecutionStack } from "@executor-js/api/server"; · 3 unmodified lines | ||
| 54 | 62 | // DbProvider rebuilds the fuma client over the same schema. |
| 55 | 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 | 74 | // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault |
| 58 | 75 | // credentials. Matches the old `createScopedExecutor`'s `orgPlugins()`. |
| 59 | 76 | export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(PluginsProvider)({ |
| 60 | 77 | plugins: (context) => |
| 61 | − executorConfig.plugins({ | |
| 78 | + cloudPluginFactory({ | |
| 62 | 79 | workosCredentials: { |
| 63 | 80 | apiKey: env.WORKOS_API_KEY, |
| 64 | 81 | clientId: env.WORKOS_CLIENT_ID, |
| @@ -66,7 +83,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 1 unmodified line | ||
| 66 | 83 | }, |
| 67 | 84 | activeToolkitSlug: |
| 68 | 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 | 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 | 190 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 83 | 191 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 84 | 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 | 198 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 91 | 199 | // user-selectable provider surface. |
| 92 | 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 | 209 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| @@ -12,14 +12,20 @@ export const withExecutionUsageTracking = <E extends Cause.YieldableError>( · 11 unmodified lines | ||
| 12 | 12 | engine |
| 13 | 13 | .execute(code, options) |
| 14 | 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 | 18 | engine |
| 17 | − .executeWithPause(code) | |
| 19 | + .executeWithPause(code, options) | |
| 18 | 20 | .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), |
| 19 | 21 | // resume doesn't count as usage |
| 20 | 22 | resume: (executionId, response) => engine.resume(executionId, response), |
| 23 | + isExecutionSettled: engine.isExecutionSettled, | |
| 21 | 24 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), |
| 22 | 25 | pausedExecutionCount: () => engine.pausedExecutionCount(), |
| 23 | 26 | hasPausedExecutions: () => engine.hasPausedExecutions(), |
| 24 | 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.