Keep the connections.create conflict (409 ConnectionAlreadyExistsError) and drop the branch's OAuth-side guard: main now resolves a taken name for fresh OAuth connects with `newConnection`, which suffixes to the next free name, so the `reconnect` discriminator and the oauth.start/complete rejections are redundant and would break reconnect.
Unified · Split
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Stale tool catalogs refresh together instead of one after another, and self-host can set the freshness window** | ||
| 6 | |||
| 7 | A tools read rebuilds every connection whose catalog has gone stale. Those rebuilds each dial their own upstream, but ran strictly one after another, so a host with several stale remote catalogs paid the sum of every server's latency on the read that tripped the TTL. The upstream listings now run concurrently, bounded so a large stale set cannot open an unbounded number of listings from one read. | ||
| 8 | |||
| 9 | Only the listings overlap. Each rebuild's catalog write stays single-file, because a self-host database is one connection issuing raw `BEGIN`/`COMMIT` and a second transaction opened while one is live fails outright. A rebuild that fails now also logs a warning naming the connection and the reason, instead of disappearing: the read still succeeds on the stale-but-working catalog and the other connections still finish, but a permanently broken connection no longer re-fails silently on every read. | ||
| 10 | |||
| 11 | Self-host also exposes the freshness window as `EXECUTOR_TOOLS_SYNC_TTL_MS`. Leave it unset for the 15-minute default, or set `off` (equivalently `null` or `false`, in any case) to disable time-based re-sync and leave stale-marking and config revision as the only refresh triggers. The value forwards to the SDK verbatim, so `0` keeps its SDK meaning: every catalog is expired on every read. A malformed, negative, or too-large-to-represent value is refused at boot rather than silently falling back to the default. | ||
| @@ -18,7 +18,8 @@ · 17 unmodified lines | |||
| 18 | "@executor-js/plugin-openapi", | 18 | "@executor-js/plugin-openapi", |
| 19 | "@executor-js/plugin-example", | 19 | "@executor-js/plugin-example", |
| 20 | "@executor-js/plugin-desktop-settings", | 20 | "@executor-js/plugin-desktop-settings", |
| 21 | "@executor-js/desktop" | 21 | "@executor-js/desktop", |
| 22 | "@executor-js/local" | ||
| 22 | ] | 23 | ] |
| 23 | ], | 24 | ], |
| 24 | "linked": [], | 25 | "linked": [], |
| @@ -29,7 +30,6 @@ · 4 unmodified lines | |||
| 29 | "onlyUpdatePeerDependentsWhenOutOfRange": true | 30 | "onlyUpdatePeerDependentsWhenOutOfRange": true |
| 30 | }, | 31 | }, |
| 31 | "ignore": [ | 32 | "ignore": [ |
| 32 | "@executor-js/local", | ||
| 33 | "@executor-js/host-mcp", | 33 | "@executor-js/host-mcp", |
| 34 | "@executor-js/ir", | 34 | "@executor-js/ir", |
| 35 | "@executor-js/runtime-deno-subprocess", | 35 | "@executor-js/runtime-deno-subprocess", |
| @@ -2,4 +2,10 @@ · 1 unmodified line | |||
| 2 | "@executor-js/sdk": patch | 2 | "@executor-js/sdk": patch |
| 3 | --- | 3 | --- |
| 4 | 4 | ||
| 5 | Creating a connection over an existing one is now rejected instead of silently overwriting it. `connections.create` fails with `ConnectionAlreadyExistsError` (HTTP 409) when the (owner, integration, name) is already taken, and `oauth.start` / `oauth.complete` reject a fresh OAuth connect that targets an existing connection name. Reconnect flows pass `reconnect: true` and keep re-minting the same connection for re-consent and token refresh. | 5 | **Creating a connection over an existing one is rejected instead of silently overwriting it** |
| 6 | |||
| 7 | `connections.create` used to upsert: a create with the same (owner, integration, name) replaced the saved connection and, for a pasted value, overwrote the stored secret itself. It now fails with the new `ConnectionAlreadyExistsError` and leaves the existing connection untouched. Remove the connection first, or pick a different name. | ||
| 8 | |||
| 9 | This adds one error to the wire contract: the `POST /connections` endpoint can answer **HTTP 409** with tag `ConnectionAlreadyExistsError`, and the `connections.create` core tool resolves the same case as `{ ok: false, error: { code: "connection_already_exists" } }`. The change is additive — no existing status, field, or success shape moves. | ||
| 10 | |||
| 11 | OAuth is unaffected. Fresh OAuth connects already resolve a taken name to the next free suffix through `newConnection`, and reconnect still re-mints the same connection on purpose. | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | --- | ||
| 2 | "executor": minor | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same** | ||
| 6 | |||
| 7 | `oauth.removeClient` deleted the client row and then deleted the client secret from the credential provider. The provider does not enlist in the caller's transaction and does not roll back with it, so an abort restored the client row while its secret stayed destroyed — a client that looks configured and can never authenticate again. The deletion now waits until the removal is durable and is discarded if the removal rolls back. With no transaction active it runs immediately, exactly as before. | ||
| 8 | |||
| 9 | Deferring the deletion is not enough on its own. The secret is stored under a key derived from the app's `(owner, slug)` identity alone, so the key outlives the row it belonged to: whoever holds that identity when the deletion finally runs owns the key. A slug registered again before the removal committed would lose the new app's secret to the old app's queued deletion — the same unauthenticatable client, reached the other way round. The deferred deletion now re-checks that the app is still gone and stands down when it is not. A removal that matched no row also no longer queues a deletion at all: it removed nothing, so it has no claim on the key, which may well hold another subject's live secret. | ||
| 10 | |||
| 11 | The same trap was reachable by plugins and they had no way out of it. `removeConnection` and `removeIntegration` run inside core's removal transaction — deliberately, so a plugin's own rows die atomically with the connection — which makes them exactly the wrong place to revoke a token at the provider's API, delete a remote object, or notify a third party. Nothing in the hooks' documentation said so, and `PluginCtx` exposed `transaction` but nothing to defer past it. | ||
| 12 | |||
| 13 | `PluginCtx` gains `afterCommit`. It runs the effect once the outermost transaction commits, discards it if that transaction rolls back, and runs it immediately when no transaction is active. The lifecycle hooks now document that they run inside core's transaction and that outside-world work belongs in `afterCommit`. | ||
| 14 | |||
| 15 | Sequencing work after your own `transaction(...)` call is not equivalent, and the documentation says so explicitly: `transaction` nests by pass-through, so inside an active transaction the inner call simply runs its effect and "afterwards" is still before any commit. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Abandoned authorization sessions no longer keep their PKCE verifier forever** | ||
| 6 | |||
| 7 | An OAuth authorization session stores its PKCE verifier so the callback can redeem the code. `complete` discarded an expired session lazily, but an _abandoned_ flow is never completed, so that check never ran for it and nothing else swept the table — the verifier sat there in plaintext indefinitely. | ||
| 8 | |||
| 9 | Starting a new authorization now sweeps sessions that have already expired. Doing it on `start` bounds the table by how often authorization is begun rather than by how often it is abandoned, and needs no scheduler in any host. A session whose completion cannot be retried is dropped rather than left behind. | ||
| 10 | |||
| 11 | The sweep only ever reaches rows the caller can already see, so one member's authorization never touches another member's sessions. It is best-effort: a sweep that fails logs a warning and lets the authorization continue. | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **GraphQL introspection no longer logs a credential carried in the endpoint URL** | ||
| 6 | |||
| 7 | `query` is a supported credential carrier, so a GraphQL endpoint can be reached with `?token=<secret>`. Introspection built its request from a URL **string**, and `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`. Every `HttpClientError` renders `${method} ${request.url}` into its `message` getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log. | ||
| 8 | |||
| 9 | The request is now built from a URL **object**, which moves the query into `request.urlParams` and clears it from `request.url`. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too. | ||
| 10 | |||
| 11 | **The query string is now normalized on the wire.** Recombination appends each pair through `URLSearchParams`, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte: | ||
| 12 | |||
| 13 | - a space written as `%20` is sent as `+` | ||
| 14 | - `~` and `!'()` are percent-encoded | ||
| 15 | - a valueless `?flag` is sent as `flag=` | ||
| 16 | |||
| 17 | Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside `request.url`, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test. | ||
| 18 | |||
| 19 | Two endpoints are now rejected up front with an `invalid-endpoint` failure rather than dialed: | ||
| 20 | |||
| 21 | - an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include | ||
| 22 | - an endpoint carrying userinfo (`https://user:pass@host/…`), which `URL` keeps in the origin, so it would stay in `request.url` and leak into error messages exactly the way a query-carried secret used to | ||
| 23 | |||
| 24 | Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent. | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Credentials are kept out of the health-check result that gets persisted** | ||
| 6 | |||
| 7 | A health check stores a sample of the probed operation's response body, plus the extracted identity, in `connection.last_health` — so whatever those carry is written to the database. The operation is user-chosen from the plugin's catalog, which means it can just as easily be a key-listing endpoint as a `/me`, and those return secrets that no scrub of the connection's own credential value can recognise, because they are different secrets entirely. | ||
| 8 | |||
| 9 | Two passes now cover both kinds of secret: | ||
| 10 | |||
| 11 | - **By key name.** Leaves whose key names a credential (`token`, `api_key`, `secret`, `authorization`, `session`, …) have their value replaced with `[redacted]`. The row itself is kept, so the live preview still shows the response shape and the identity picker still works. Keys that merely contain a matching substring, such as `author`, are left alone. camelCase spellings are recognised too: `accessToken`, `refreshToken`, `clientSecret`, `privateKey` and `sessionId` have no separator before the credential word, so a matcher that only looks for one reads them as innocent. | ||
| 12 | - **By value.** The OpenAPI health check removes the connection's own credential value from each sampled value, covering the other direction: a body that echoes back the key it was authenticated with under an innocent-looking name. This runs before the sample's 120-char truncation, not after — truncating first leaves a prefix of a long credential that an exact-value scrub can no longer match, and that prefix is what would be persisted. | ||
| 13 | |||
| 14 | The key check reads a dotted path two ways. It uses the nearest NAMED segment, because array elements are named by index: `{"tokens": ["sk-live-…"]}` produces the path `tokens.0`, and testing the literal `"0"` matches nothing. It also uses an enclosing array container, because a key listing returns `{"api_keys": [{"value": "sk-live-…"}]}`, whose path is `api_keys.0.value` — the nearest named segment there is the innocent `value`, and only the array's own key says what the collection holds. A collection whose key names nothing, such as `names.0`, is still shown in full. | ||
| 15 | |||
| 16 | The extracted `identity` goes through both passes as well. It is read straight off the raw body, so it previously bypassed them even though it is persisted the same way, and `identityField` is user-chosen from whatever the picker listed. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Fix: allow MCP integrations to declare OAuth scopes when resource metadata omits them** | ||
| 6 | |||
| 7 | MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes | ||
| 8 | take precedence over protected-resource scope discovery, so servers with fixed | ||
| 9 | scopes can connect even when their dynamically registered OAuth client has no | ||
| 10 | resource identifier. Existing integrations without declared scopes keep | ||
| 11 | discovering them from the server at connect time. | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Idle MCP connections age out on the pool's next acquire, even when their identity is never dialled again** | ||
| 6 | |||
| 7 | The pool's five-minute idle window was only consulted against the entry being requested, so an identity that was never asked for a second time was never examined a second time. Its session stayed open and authenticated for as long as the pool lived, holding the bearer token or API key it was dialled with. The advertised bound applied only to connections that happened to be reused. | ||
| 8 | |||
| 9 | `acquire` now sweeps every entry past the window, closing each one, rather than just the entry matching the key. This stays lazy in the sense the pool intends — activity drives it, there is no timer and no background fiber — and the map holds at most one entry per identity, so the scan is trivial. | ||
| 10 | |||
| 11 | Because the sweep is paid for by whichever invocation acquires next, it cannot be allowed to stall that caller. The expired entries leave the pool synchronously, before any close is awaited, and the closes then run concurrently with each one bounded by a two-second timeout — so a server that accepts a close and goes quiet is abandoned rather than waited on, and cannot hold up an unrelated request or the connections queued behind it. | ||
| 12 | |||
| 13 | Reuse is unchanged: an entry still inside the window is left alone, and a second call for the same identity still gets the parked session rather than a fresh dial. | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/local": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **An unsupported method probed before `initialize` no longer kills the MCP connection** | ||
| 6 | |||
| 7 | Only `initialize` can open a session, so the streamable-HTTP transport answered every other pre-session method with HTTP 400 + `-32000 Server not initialized`. A 400 is a transport-level failure, so clients dropped the connection instead of treating it as one request failing — a client that opens with an optional probe (MCP 2026-07-28 clients lead with `server/discover`) was disconnected before it could fall back to `initialize`. Over `executor mcp`, which bridges this endpoint to stdio, that closed the client's pipe outright. | ||
| 8 | |||
| 9 | Pre-session dispatch now answers any method other than `initialize` with `-32601 Method not found` on a normal 200, which is a per-request error, so the connection survives and the handshake proceeds. This replaces only that one answer: a POST with a bad `Accept` or `Content-Type` still gets the transport's 406 or 415, and a message that is not a valid JSON-RPC request still gets its parse error. | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/host-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Evict idle MCP sessions instead of holding them for the lifetime of the process. The in-process session store only released a session when the client sent `DELETE /mcp`, which the MCP client SDK's `transport.close()` never sends and a crashed client cannot send, so every `initialize` permanently retained an `McpServer`, its tool registry, and an `ExecutionEngine`. Sessions are now stamped on create and on each request, and a timer disposes anything idle past `sessionIdleTtlMs` (30 minutes by default). An open server-to-client stream does not defer eviction, matching how cloud's session alarm destroys a session once it passes its running-lease ceiling; an evicted id answers 404 `-32001`, which is the client's cue to re-initialize. | ||
| 6 | |||
| 7 | A request in flight holds its session: idleness counts from when a call ends, not from when it started, so a tool call slower than the idle window is never cut off mid-flight. Disposal also shuts the session's execution engine down rather than only dropping the reference, which is what ends its detached sandbox fibers, and a handle that fails to close is now logged with its session id instead of being discarded silently. | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-mcp": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Stdio MCP servers no longer inherit executor's full environment** | ||
| 6 | |||
| 7 | A stdio MCP server that declared any `env` at all was spawned with every environment variable this process holds. The MCP SDK already guards against that: it spawns with `{ ...getDefaultEnvironment(), ...serverParams.env }`, where `getDefaultEnvironment()` is a sudo-style safe-list of `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM` and `USER`. Passing `{ ...process.env, ...config.env }` did not add to that safe-list, it overwrote it. In practice, adding one third-party `npx` server went from "this server can see the API key I gave it" to "this server also holds `EXECUTOR_SECRET_KEY`, the key that decrypts every other stored credential, plus `EXECUTOR_AUTH_TOKEN` and `DATABASE_URL`". The leak sat on the `config.env` branch — the branch a credential-bearing integration takes. | ||
| 8 | |||
| 9 | A stdio server now receives the SDK's safe-list, the variables declared on the source config, and one short allowlist of infrastructure variables read from the host: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (both spellings), `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. Those carry no credential, no source config declares them, and a server behind a corporate proxy or an intercepting CA cannot reach anything without them — the same reasoning and the same list `service install` already uses when it bakes a supervised unit's minimal environment. The declared `env` wins on a key collision. On Windows that collision is resolved case-insensitively, because the OS treats `Path` and `PATH` as one variable while a JavaScript spread does not: a declared `http_proxy` now replaces an inherited `HTTP_PROXY` instead of travelling beside it, which would have left the child reading whichever spelling Windows resolved first. | ||
| 10 | |||
| 11 | If a stdio server relied on some other variable arriving from the host, set it explicitly on the source's `env`. That is now the only way anything beyond the lists above reaches a server, and it is the mechanism that already existed for it. | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **A credential-store outage no longer costs an OAuth connection its grant** | ||
| 6 | |||
| 7 | Refreshing an OAuth token spends the stored refresh token: the authorization server rotates it, so the copy we sent stops working the moment the grant succeeds and the rotated one is the only thing that can mint again. Persisting the rotated token first bounds what a partial write can lose, but it cannot help when the store is refusing writes outright — the grant has already run, there is nowhere to put the successor, and every later refresh replays a token the server has revoked. The connection then reports `invalid_grant` and demands a re-auth over what was only a storage blip. | ||
| 8 | |||
| 9 | The refresh is now gated on a store that is proven writable. Before the grant runs, it writes a fixed value to an item of its own that holds no credential and sits in the same partition as the connection's tokens. A store that cannot take that write fails the resolve while the stored refresh token is still valid, so the connection recovers on its own once the store does. | ||
| 10 | |||
| 11 | The probe deliberately does not test the store by rewriting the refresh token with the value it just read. That is a read-then-write with no compare-and-set, and two instances refreshing one connection would lose the newer token to it: one reads the stored token, the other spends that same token and stores its rotated replacement, and the first then writes the spent one back over the replacement. The connection would die exactly the way the gate is meant to prevent. | ||
| 12 | |||
| 13 | The probe also removes a write rather than adding one in the common case. Authorization servers that do not rotate hand back the same refresh token, and that value is no longer re-persisted when it has not changed — a rotated token never matches, so the write that matters still happens. | ||
| @@ -0,0 +1,35 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Keep token material out of OAuth token-endpoint error messages** | ||
| 6 | |||
| 7 | A token-endpoint failure renders a preview of the upstream body into its | ||
| 8 | message, and that message is persisted onto connection health, returned to the | ||
| 9 | caller, and carried into telemetry. On a malformed HTTP 200 the body being | ||
| 10 | previewed is a _successful_ token response, so an access token and a refresh | ||
| 11 | token could be rendered into it. | ||
| 12 | |||
| 13 | The preview is now built from an allowlist of fields that are safe to show | ||
| 14 | (`error`, `errors`, `error_description`, `error_uri`, plus `code`, `message`, | ||
| 15 | and `detail` nested inside them) instead of a denylist of fields to hide. A | ||
| 16 | field nobody anticipated is omitted by default rather than printed by default. | ||
| 17 | Keys stay visible and only non-allowlisted string values are replaced, so an | ||
| 18 | operator can still read the shape of what the server sent. `code` is readable | ||
| 19 | only when nested, because at the top level of a token response it is the RFC | ||
| 20 | 6749 authorization code. | ||
| 21 | |||
| 22 | Form-encoded bodies take the same allowlist, the walk over a body is | ||
| 23 | depth-bounded, and the failure summary records the token endpoint's hostname | ||
| 24 | rather than its full URL, which can carry identifiers in its path. | ||
| 25 | |||
| 26 | On that same malformed-200 path the failure no longer keeps the underlying | ||
| 27 | rejection as its `cause`. That rejection carries the parsed token response, so | ||
| 28 | keeping it put the raw tokens back into anything that renders the whole failure | ||
| 29 | rather than only its message. Everything the path needs from the body — the | ||
| 30 | status, the error code, the redacted preview — is read before the failure is | ||
| 31 | built. A transport failure still keeps its cause, which is what tells a DNS miss | ||
| 32 | apart from a refused connection. | ||
| 33 | |||
| 34 | No public API changes. The dead-grant classification added for HTTP 200 refresh | ||
| 35 | refusals is unaffected: it reads the HTTP status, not the rendered preview. | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-openapi": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Multipart file fields in an OpenAPI spec now accept and send real files. A `multipart/form-data` property typed as a binary or byte string is rewritten into the SDK's tool-file schema when the tool is extracted, so an agent supplies a file the same way it does everywhere else. On invocation those values are decoded back into `File`/`Blob` parts — as bare properties and inside arrays, with a per-property `encoding.contentType` applied to each file part — instead of being JSON-stringified into the form body, which is what upstreams were previously rejecting. A file whose base64 payload does not decode now fails the invocation and names the field, rather than sending the file envelope as JSON. | ||
| 6 | |||
| 7 | The rewrite advertises only the shapes the request encoder can deliver. Two are deliberately left alone: | ||
| 8 | |||
| 9 | - A binary field nested inside an object property. Only top-level multipart properties and direct items of a top-level array property become form parts. | ||
| 10 | - A multipart body schema, or one of its properties, behind a `$ref`. Component schemas are carried through unresolved by design — the streaming compile path never materializes `components.schemas` — so a `$ref`'d file field keeps its declared binary string type. | ||
| 11 | |||
| 12 | The rewrite reads the request schema's own `properties` map rather than walking every object key, so a `default`, `example`, or vendor extension that happens to look like a binary string schema is untouched. Descriptions, titles, and nullability on the replaced field are carried onto the file schema. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Persist the Connect-an-agent card's transport, artifact, integration-search, and approval preferences in the browser so its generated MCP install command remains stable across page reloads. | ||
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Keep Last-Event-ID recovery scoped to its originating MCP stream. | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/cloud": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Team pricing is per member with unlimited executions** | ||
| 6 | |||
| 7 | The Team plan moves from $150 per organization with a 250,000-execution | ||
| 8 | allowance to $15 per member per month with unlimited executions. The | ||
| 9 | `members` feature is unarchived in `autumn.config.ts` and billed in arrears | ||
| 10 | on the seat count the app reports; Free keeps its 3-member, 100,000-execution | ||
| 11 | shape and Enterprise stays custom with seat usage tracked for visibility. | ||
| 12 | |||
| 13 | Seat counts reconcile from a full WorkOS recount (active members only — | ||
| 14 | pending invites hold a seat for the plan gate but are not billed) after | ||
| 15 | member removal, invitation acceptance, organization creation, and on every | ||
| 16 | login callback, which also picks up joins the app never sees a mutation for | ||
| 17 | (SSO JIT provisioning, join by domain, dashboard edits). Plans that predate | ||
| 18 | seat pricing have no members balance and are skipped, so existing | ||
| 19 | subscriptions keep billing exactly as before on their current plan version. | ||
| 20 | |||
| 21 | The plans page, billing page, and marketing pricing cards now show the | ||
| 22 | per-member price. | ||
| @@ -32,18 +32,22 @@ const github = await createEmulator({ service: "github", port: 4501 }); · 31 unmodified lines | |||
| 32 | `servers`) when a proxy fronts the emulator — the bind stays on `port`. | 32 | `servers`) when a proxy fronts the emulator — the bind stays on `port`. |
| 33 | 33 | ||
| 34 | **Attach to a running one** (another process, or hosted on Cloudflare with | 34 | **Attach to a running one** (another process, or hosted on Cloudflare with |
| 35 | Durable Object state at `https://<service>.emulators.dev` / | 35 | Durable Object state at `https://<service>.<instance>.emulators.dev` — |
| 36 | `https://<service>.<instance>.emulators.dev` — catalog at | 36 | catalog at `GET https://emulators.dev/_emulate/services`). Service hosts |
| 37 | `GET https://emulators.dev/_emulate/services`): | 37 | (`https://<service>.emulators.dev`) are control plane only, with no shared |
| 38 | default instance behind them: create a private instance first and connect to | ||
| 39 | the returned `providerBaseUrl`. | ||
| 38 | 40 | ||
| 39 | ```ts | 41 | ```ts |
| 40 | import { connectEmulator } from "@executor-js/emulate"; | 42 | import { connectEmulator } from "@executor-js/emulate"; |
| 41 | const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" }); | 43 | const created = await fetch("https://resend.emulators.dev/_emulate/instances", { method: "POST" }); |
| 44 | const { providerBaseUrl } = await created.json(); | ||
| 45 | const resend = await connectEmulator({ baseUrl: providerBaseUrl }); | ||
| 42 | // optional: { service: "resend" } verifies the manifest on connect | 46 | // optional: { service: "resend" } verifies the manifest on connect |
| 43 | ``` | 47 | ``` |
| 44 | 48 | ||
| 45 | Create a private hosted instance with `POST /_emulate/instances` on the | 49 | In e2e scenarios use `createEmulatorInstance` from |
| 46 | service host. | 50 | `e2e/src/emulator-instance.ts`, which wraps this. |
| 47 | 51 | ||
| 48 | ## The typed control-plane client | 52 | ## The typed control-plane client |
| 49 | 53 | ||
| @@ -113,12 +117,11 @@ The emulate repo's own `AGENTS.md` / `README.md` carry the current build, · 63 unmodified lines | |||
| 113 | publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read | 117 | publish, and deploy commands (npm + Cloudflare creds are in 1Password). Read |
| 114 | them there rather than memorizing flags here — they move. | 118 | them there rather than memorizing flags here — they move. |
| 115 | 119 | ||
| 116 | **A hot deploy can redden other people's e2e.** `*.emulators.dev` service | 120 | **A hot deploy can redden other people's e2e.** The `emulate-hosts` worker |
| 117 | hosts are shared infrastructure; a control-plane regression there has failed | 121 | behind `*.emulators.dev` is shared infrastructure; a control-plane regression |
| 118 | unrelated PRs' suites before. When a scenario needs isolation or a behavior | 122 | there has failed unrelated PRs' suites before. Scenarios always run on private |
| 119 | that isn't deployed yet, pin to a published package version or mint a private | 123 | per-run instances (`POST /_emulate/instances`); when a scenario needs behavior |
| 120 | per-run instance (`POST /_emulate/instances`) instead of mutating the shared | 124 | that isn't deployed yet, pin to a published package version. |
| 121 | service host. | ||
| 122 | 125 | ||
| 123 | ## Gotchas | 126 | ## Gotchas |
| 124 | 127 | ||
| @@ -29,6 +29,20 @@ join the same traces via traceparent). · 28 unmodified lines | |||
| 29 | 29 | ||
| 30 | **Span names worth querying** (and their custom attrs): | 30 | **Span names worth querying** (and their custom attrs): |
| 31 | 31 | ||
| 32 | - `mcp.execute` / `mcp.execute.resume` — `mcp.execute.mode` | ||
| 33 | (`pausable`/`inline`), `mcp.execute.code_length`, and | ||
| 34 | `mcp.execute.outcome` (`ok`/`fail`/`paused`) with, on failures, | ||
| 35 | `mcp.execute.error_kind` (`type_error` | `reference_error` | | ||
| 36 | `syntax_error` | `range_error` | `tool_error` | `timeout` | | ||
| 37 | `resource_limit` | `serialization_error` | `thrown` | `unknown`). | ||
| 38 | Sandbox script failures ride the MCP success channel, so `status.code` | ||
| 39 | stays OK — filter on these attributes, not span status. Spans from | ||
| 40 | before the attributes shipped carry neither; absence is not success. | ||
| 41 | Also `mcp.execute.result_chars` (compact-JSON size of the returned | ||
| 42 | value, pre-truncation; -1 = unmeasurable), `mcp.execute.log_chars`, | ||
| 43 | `mcp.execute.emitted` — the dump-vs-narrow signal (the model preview | ||
| 44 | truncates at 30k chars, so `result_chars > 30000` means the model tried | ||
| 45 | to pull a truncated blob into context). | ||
| 32 | - `executor.tool.execute` — `mcp.tool.name` (full address), and since | 46 | - `executor.tool.execute` — `mcp.tool.name` (full address), and since |
| 33 | PR #992: `executor.tool.outcome` (`ok`/`fail`), | 47 | PR #992: `executor.tool.outcome` (`ok`/`fail`), |
| 34 | `executor.tool.error_code`, `executor.tool.error_status`, | 48 | `executor.tool.error_code`, `executor.tool.error_status`, |
| @@ -39,7 +53,10 @@ join the same traces via traceparent). · 4 unmodified lines | |||
| 39 | `base_url`, and since PR #992 `http.status_code`. | 53 | `base_url`, and since PR #992 `http.status_code`. |
| 40 | - `mcp.request` (outer) — `mcp.auth.organization_id`, | 54 | - `mcp.request` (outer) — `mcp.auth.organization_id`, |
| 41 | `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), | 55 | `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), |
| 42 | MCP client fingerprint (`mcp.client.name`…). | 56 | MCP client fingerprint (`mcp.client.name`…), and on managed-cloud |
| 57 | `execute`/`execute-action` calls `mcp.execute.code` (the script itself, | ||
| 58 | capped at 10k chars — cloud-only content capture; local/self-host | ||
| 59 | telemetry never records content). | ||
| 43 | 60 | ||
| 44 | **Recipe — error signatures by class (the daily-digest query):** | 61 | **Recipe — error signatures by class (the daily-digest query):** |
| 45 | 62 | ||
| @@ -10,6 +10,18 @@ concurrency: · 9 unmodified lines | |||
| 10 | group: ci-${{ github.ref }} | 10 | group: ci-${{ github.ref }} |
| 11 | cancel-in-progress: true | 11 | cancel-in-progress: true |
| 12 | 12 | ||
| 13 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 14 | # production PostHog project from every e2e shard: the analytics layer | ||
| 15 | # (integration_added, execution_completed, artifact_*) and the | ||
| 16 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 17 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 18 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 19 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 20 | env: | ||
| 21 | DO_NOT_TRACK: "1" | ||
| 22 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 23 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 24 | |||
| 13 | jobs: | 25 | jobs: |
| 14 | changes: | 26 | changes: |
| 15 | name: Changed paths | 27 | name: Changed paths |
| @@ -18,6 +30,7 @@ jobs: · 2 unmodified lines | |||
| 18 | outputs: | 30 | outputs: |
| 19 | desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} | 31 | desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} |
| 20 | selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }} | 32 | selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }} |
| 33 | cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }} | ||
| 21 | steps: | 34 | steps: |
| 22 | - uses: actions/checkout@v4 | 35 | - uses: actions/checkout@v4 |
| 23 | 36 | ||
| @@ -41,6 +54,14 @@ jobs: · 17 unmodified lines | |||
| 41 | - "packages/kernel/runtime-quickjs/**" | 54 | - "packages/kernel/runtime-quickjs/**" |
| 42 | - "packages/plugins/**" | 55 | - "packages/plugins/**" |
| 43 | - "packages/react/**" | 56 | - "packages/react/**" |
| 57 | cloud_closure: | ||
| 58 | - ".github/workflows/**" | ||
| 59 | - "bun.lock" | ||
| 60 | - "package.json" | ||
| 61 | - "turbo.json" | ||
| 62 | - "apps/cloud/**" | ||
| 63 | - "packages/**" | ||
| 64 | |||
| 44 | selfhost_docker_smoke: | 65 | selfhost_docker_smoke: |
| 45 | - ".github/workflows/**" | 66 | - ".github/workflows/**" |
| 46 | - ".dockerignore" | 67 | - ".dockerignore" |
| @@ -136,17 +157,26 @@ jobs: · 89 unmodified lines | |||
| 136 | 157 | ||
| 137 | - run: bun run typecheck | 158 | - run: bun run typecheck |
| 138 | 159 | ||
| 160 | # The committed routeTree.gen.ts files are generated but checked in, and | ||
| 161 | # they carry @ts-nocheck — so the typecheck above structurally CANNOT see | ||
| 162 | # a route that the contract declares and the tree lacks. That drift has | ||
| 163 | # already shipped a live 404 once. Regenerate and diff instead. | ||
| 164 | - run: bun run check:routes | ||
| 165 | |||
| 139 | test: | 166 | test: |
| 140 | name: Test | 167 | name: Test |
| 141 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 168 | runs-on: blacksmith-16vcpu-ubuntu-2404 |
| 142 | timeout-minutes: 15 | 169 | timeout-minutes: 15 |
| 143 | # Tuned for Blacksmith's 4 vCPU runners. | 170 | # Run eight independent packages at once and cap each Vitest process at two |
| 171 | # workers. This uses all 16 vCPUs without every nested runner seeing the | ||
| 172 | # whole machine and oversubscribing it. | ||
| 144 | env: | 173 | env: |
| 145 | TURBO_API: ${{ vars.TURBO_API }} | 174 | TURBO_API: ${{ vars.TURBO_API }} |
| 146 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} | 175 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} |
| 147 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | 176 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} |
| 148 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} | 177 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} |
| 149 | TURBO_TEST_CONCURRENCY: 4 | 178 | TURBO_TEST_CONCURRENCY: 8 |
| 179 | VITEST_MAX_WORKERS: 2 | ||
| 150 | steps: | 180 | steps: |
| 151 | - uses: actions/checkout@v4 | 181 | - uses: actions/checkout@v4 |
| 152 | 182 | ||
| @@ -171,6 +201,15 @@ jobs: · 18 unmodified lines | |||
| 171 | 201 | ||
| 172 | - run: bun install --frozen-lockfile | 202 | - run: bun install --frozen-lockfile |
| 173 | 203 | ||
| 204 | # Fail loudly if a load-bearing patched dependency (e.g. the agents MCP | ||
| 205 | # transport hang fix) resolved to a stale, unpatched dist. Without this, | ||
| 206 | # a bun cache edge case can leave the patch unapplied while everything | ||
| 207 | # still builds and tests green, and prod ships the pre-fix transport with | ||
| 208 | # no signal. This is just a few file reads plus greps, so ~zero cost. | ||
| 209 | - run: bun run check:patches | ||
| 210 | |||
| 211 | - run: bun run --cwd e2e test:ci-shard | ||
| 212 | |||
| 174 | - run: bun run test | 213 | - run: bun run test |
| 175 | 214 | ||
| 176 | e2e: | 215 | e2e: |
| @@ -179,14 +218,41 @@ jobs: · 2 unmodified lines | |||
| 179 | fail-fast: false | 218 | fail-fast: false |
| 180 | matrix: | 219 | matrix: |
| 181 | include: | 220 | include: |
| 182 | # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, | 221 | # The planner assigns every file exactly once using recorded slow-file |
| 183 | # four fatter shards keep the longest shard below selfhost while saving | 222 | # durations plus a conservative weight for new tests. The cloud DB's |
| 184 | # four runner boots and four warm cache restores. | 223 | # connection teardown and concurrent socket protocol have dedicated |
| 185 | - { target: cloud, shard: 1/4, shard-name: 1of4 } | 224 | # regression tests; these shards balance wall clock, not hide retries. |
| 186 | - { target: cloud, shard: 2/4, shard-name: 2of4 } | 225 | - { target: cloud, shard-index: 1, shard-name: 1of16 } |
| 187 | - { target: cloud, shard: 3/4, shard-name: 3of4 } | 226 | - { target: cloud, shard-index: 2, shard-name: 2of16 } |
| 188 | - { target: cloud, shard: 4/4, shard-name: 4of4 } | 227 | - { target: cloud, shard-index: 3, shard-name: 3of16 } |
| 189 | - target: selfhost | 228 | - { target: cloud, shard-index: 4, shard-name: 4of16 } |
| 229 | - { target: cloud, shard-index: 5, shard-name: 5of16 } | ||
| 230 | - { target: cloud, shard-index: 6, shard-name: 6of16 } | ||
| 231 | - { target: cloud, shard-index: 7, shard-name: 7of16 } | ||
| 232 | - { target: cloud, shard-index: 8, shard-name: 8of16 } | ||
| 233 | - { target: cloud, shard-index: 9, shard-name: 9of16 } | ||
| 234 | - { target: cloud, shard-index: 10, shard-name: 10of16 } | ||
| 235 | - { target: cloud, shard-index: 11, shard-name: 11of16 } | ||
| 236 | - { target: cloud, shard-index: 12, shard-name: 12of16 } | ||
| 237 | - { target: cloud, shard-index: 13, shard-name: 13of16 } | ||
| 238 | - { target: cloud, shard-index: 14, shard-name: 14of16 } | ||
| 239 | - { target: cloud, shard-index: 15, shard-name: 15of16 } | ||
| 240 | - { target: cloud, shard-index: 16, shard-name: 16of16 } | ||
| 241 | - { target: selfhost, shard-index: 1, shard-name: 1of10 } | ||
| 242 | - { target: selfhost, shard-index: 2, shard-name: 2of10 } | ||
| 243 | - { target: selfhost, shard-index: 3, shard-name: 3of10 } | ||
| 244 | - { target: selfhost, shard-index: 4, shard-name: 4of10 } | ||
| 245 | - { target: selfhost, shard-index: 5, shard-name: 5of10 } | ||
| 246 | - { target: selfhost, shard-index: 6, shard-name: 6of10 } | ||
| 247 | - { target: selfhost, shard-index: 7, shard-name: 7of10 } | ||
| 248 | - { target: selfhost, shard-index: 8, shard-name: 8of10 } | ||
| 249 | - { target: selfhost, shard-index: 9, shard-name: 9of10 } | ||
| 250 | - { target: selfhost, shard-index: 10, shard-name: 10of10 } | ||
| 251 | # Local files own their server, browser and data directory. Separate | ||
| 252 | # runners preserve that isolation while removing its 69-second serial | ||
| 253 | # lane from the two-minute critical path. | ||
| 254 | - { target: local, shard-index: 1, shard-name: 1of2 } | ||
| 255 | - { target: local, shard-index: 2, shard-name: 2of2 } | ||
| 190 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 256 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 191 | timeout-minutes: 30 | 257 | timeout-minutes: 30 |
| 192 | steps: | 258 | steps: |
| @@ -222,26 +288,31 @@ jobs: · 29 unmodified lines | |||
| 222 | 288 | ||
| 223 | # Install from e2e so bunx resolves ITS pinned playwright (the version | 289 | # Install from e2e so bunx resolves ITS pinned playwright (the version |
| 224 | # the tests run against) rather than floating to the latest. | 290 | # the tests run against) rather than floating to the latest. |
| 291 | # Blacksmith's Ubuntu image carries the official GitHub runner system | ||
| 292 | # dependencies. Restore the pinned browser binaries without apt-updating | ||
| 293 | # every matrix machine. | ||
| 225 | - name: Install Playwright Chromium | 294 | - name: Install Playwright Chromium |
| 226 | run: bunx playwright install --with-deps chromium chromium-headless-shell | 295 | run: bunx playwright install chromium chromium-headless-shell |
| 227 | working-directory: e2e | 296 | working-directory: e2e |
| 228 | 297 | ||
| 229 | # The globalsetup boots the target's own dev server (ports are claimed | 298 | # Each target either boots its own shared dev server or lets each file own |
| 230 | # per checkout, so this is hermetic) and tears it down after the run. | 299 | # its server. Ports and data paths are hermetic in both cases. |
| 231 | # --retry=2: browser scenarios can still hit isolated waitFor timeouts | 300 | # Do not retry scenarios: retries hide flakes and multiply slow timeout |
| 232 | # (single-test waitFor timeouts, not systemic failures); a retry on the | 301 | # failures. The fixtures and process lifecycle are deterministic enough |
| 233 | # same booted stack clears them. | 302 | # that the first result is the result. |
| 234 | - name: Run cloud scenarios | 303 | - name: Run cloud shard |
| 235 | if: matrix.target == 'cloud' | 304 | if: matrix.target == 'cloud' |
| 236 | env: | 305 | env: |
| 237 | MCP_SESSION_TIMEOUT_MS: "3000" | 306 | MCP_SESSION_TIMEOUT_MS: "3000" |
| 238 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" | 307 | # Still 18x shorter than production, but long enough for a cold Vite |
| 239 | run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} | 308 | # resume route to compile under a fully loaded CI runner. |
| 309 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "30000" | ||
| 310 | run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }} | ||
| 240 | working-directory: e2e | 311 | working-directory: e2e |
| 241 | 312 | ||
| 242 | - name: Run selfhost scenarios | 313 | - name: Run scenarios |
| 243 | if: matrix.target == 'selfhost' | 314 | if: matrix.target != 'cloud' |
| 244 | run: bunx vitest run --project selfhost --retry=2 | 315 | run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }} |
| 245 | working-directory: e2e | 316 | working-directory: e2e |
| 246 | 317 | ||
| 247 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in | 318 | # Failed runs keep their trace.zip / session.mp4 / step screenshots in |
| @@ -254,13 +325,23 @@ jobs: · 6 unmodified lines | |||
| 254 | path: e2e/runs/ | 325 | path: e2e/runs/ |
| 255 | retention-days: 7 | 326 | retention-days: 7 |
| 256 | 327 | ||
| 257 | e2e-local: | 328 | cloud-closure: |
| 258 | name: E2E (stdio MCP) | 329 | name: Cloud evaluated closure |
| 259 | # Skipped on pull_request: the local scenario boots a real `executor web` | 330 | needs: changes |
| 260 | # plus a browser and is currently flaky on PRs. Still runs on push to main. | 331 | if: needs.changes.outputs.cloud_closure == 'true' |
| 261 | if: github.event_name != 'pull_request' | ||
| 262 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 332 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 263 | timeout-minutes: 20 | 333 | timeout-minutes: 20 |
| 334 | env: | ||
| 335 | TURBO_API: ${{ vars.TURBO_API }} | ||
| 336 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} | ||
| 337 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} | ||
| 338 | TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} | ||
| 339 | # Every cold isolate evaluates this closure before it can answer a | ||
| 340 | # request, and it only ever grows by accident - a barrel export or a new | ||
| 341 | # module-scope import quietly pulls megabytes into the server graph. The | ||
| 342 | # budget is a ratchet just above today's size, not a discovered limit: | ||
| 343 | # when it trips, make the new dependency lazy rather than raising it. | ||
| 344 | START_CLOSURE_BUDGET_MB: 13.5 | ||
| 264 | steps: | 345 | steps: |
| 265 | - uses: actions/checkout@v4 | 346 | - uses: actions/checkout@v4 |
| 266 | 347 | ||
| @@ -276,41 +357,17 @@ jobs: · 9 unmodified lines | |||
| 276 | restore-keys: | | 357 | restore-keys: | |
| 277 | ${{ runner.os }}-bun-1.3.11- | 358 | ${{ runner.os }}-bun-1.3.11- |
| 278 | 359 | ||
| 279 | # The local scenarios boot a real `executor web` (which spawns a Node | ||
| 280 | # sidecar) and some drive a browser, so pin Node 24 and install Chromium. | ||
| 281 | - uses: actions/setup-node@v4 | 360 | - uses: actions/setup-node@v4 |
| 282 | with: | 361 | with: |
| 283 | node-version: 24 | 362 | node-version: 24 |
| 284 | 363 | ||
| 285 | - run: bun install --frozen-lockfile | 364 | - run: bun install --frozen-lockfile |
| 286 | 365 | ||
| 287 | - name: Cache Playwright browsers | 366 | - run: bun run build |
| 288 | uses: actions/cache@v4 | 367 | working-directory: apps/cloud |
| 289 | with: | ||
| 290 | path: ~/.cache/ms-playwright | ||
| 291 | key: ${{ runner.os }}-playwright-1.60.0 | ||
| 292 | restore-keys: | | ||
| 293 | ${{ runner.os }}-playwright- | ||
| 294 | 368 | ||
| 295 | # `chromium` and the new `chromium-headless-shell` ship as separate | 369 | - run: node scripts/start-closure.mjs |
| 296 | # downloads; the browser-driven scenarios launch the headless shell. | 370 | working-directory: apps/cloud |
| 297 | # Install from e2e so bunx resolves ITS pinned playwright (the version the | ||
| 298 | # tests run against) rather than floating to the latest, which would fetch | ||
| 299 | # a browser build the test runtime does not look for. | ||
| 300 | - name: Install Playwright Chromium | ||
| 301 | run: bunx playwright install --with-deps chromium chromium-headless-shell | ||
| 302 | working-directory: e2e | ||
| 303 | |||
| 304 | # The `local` project is excluded from the default `test` chain (each | ||
| 305 | # scenario boots its own `executor web`). Run just the stdio MCP scenario | ||
| 306 | # here: it is the auto-connect / env-as-secret regression guard, and | ||
| 307 | # running it alone avoids the boot-resource accumulation and the | ||
| 308 | # pre-existing browser flakiness of the rest of the local suite. Expanding | ||
| 309 | # to the full `local` project (bun run test:local) is a follow-up once | ||
| 310 | # those are stabilized. | ||
| 311 | - name: Run the stdio MCP scenario | ||
| 312 | run: bunx vitest run --project local local/stdio-mcp.test.ts | ||
| 313 | working-directory: e2e | ||
| 314 | 371 | ||
| 315 | desktop-smoke: | 372 | desktop-smoke: |
| 316 | name: Desktop smoke build | 373 | name: Desktop smoke build |
| @@ -362,7 +419,13 @@ jobs: · 45 unmodified lines | |||
| 362 | - name: Run sidecar outside the workspace | 419 | - name: Run sidecar outside the workspace |
| 363 | working-directory: apps/desktop | 420 | working-directory: apps/desktop |
| 364 | run: | | 421 | run: | |
| 365 | 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 \ | ||
| 366 | -v "$PWD/resources/executor:/opt/executor:ro" \ | 429 | -v "$PWD/resources/executor:/opt/executor:ro" \ |
| 367 | debian:bookworm-slim /opt/executor/executor daemon run --foreground \ | 430 | debian:bookworm-slim /opt/executor/executor daemon run --foreground \ |
| 368 | --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke | 431 | --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke |
| @@ -407,4 +470,35 @@ jobs: · 38 unmodified lines | |||
| 407 | context: . | 470 | context: . |
| 408 | file: apps/host-selfhost/Dockerfile | 471 | file: apps/host-selfhost/Dockerfile |
| 409 | push: false | 472 | push: false |
| 473 | load: true | ||
| 410 | tags: executor-selfhost:ci | 474 | tags: executor-selfhost:ci |
| 475 | |||
| 476 | # Building alone doesn't catch startup crashes (e.g. a runtime-resolved | ||
| 477 | # package missing from the packaged runtime), so boot the container and | ||
| 478 | # require a healthy /api/health before calling the image good. | ||
| 479 | - name: Boot image and check health | ||
| 480 | shell: bash | ||
| 481 | run: | | ||
| 482 | set -euo pipefail | ||
| 483 | docker run -d --name selfhost-smoke -p 4788:4788 \ | ||
| 484 | -e BETTER_AUTH_SECRET="$(openssl rand -hex 32)" \ | ||
| 485 | -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=smoke@example.com \ | ||
| 486 | -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD="$(openssl rand -hex 16)" \ | ||
| 487 | -e EXECUTOR_WEB_BASE_URL=http://localhost:4788 \ | ||
| 488 | executor-selfhost:ci | ||
| 489 | for i in $(seq 1 30); do | ||
| 490 | if [ "$(docker inspect -f '{{.State.Running}}' selfhost-smoke)" != "true" ]; then | ||
| 491 | echo "container exited during startup" | ||
| 492 | docker logs selfhost-smoke | ||
| 493 | exit 1 | ||
| 494 | fi | ||
| 495 | if curl -fsS http://localhost:4788/api/health >/dev/null 2>&1; then | ||
| 496 | echo "healthy after ${i}s" | ||
| 497 | docker logs selfhost-smoke | ||
| 498 | exit 0 | ||
| 499 | fi | ||
| 500 | sleep 1 | ||
| 501 | done | ||
| 502 | echo "health check never passed" | ||
| 503 | docker logs selfhost-smoke | ||
| 504 | exit 1 | ||
| @@ -9,6 +9,16 @@ concurrency: · 8 unmodified lines | |||
| 9 | group: deploy-production | 9 | group: deploy-production |
| 10 | cancel-in-progress: false | 10 | cancel-in-progress: false |
| 11 | 11 | ||
| 12 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 13 | # than the ones that look like they run product code: the first pass guessed, | ||
| 14 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 15 | # where the product is not executed, so the blanket application is the cheap | ||
| 16 | # structural answer. | ||
| 17 | env: | ||
| 18 | DO_NOT_TRACK: "1" | ||
| 19 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 20 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 21 | |||
| 12 | jobs: | 22 | jobs: |
| 13 | migrate: | 23 | migrate: |
| 14 | name: Migrate database | 24 | name: Migrate database |
| @@ -77,12 +87,30 @@ jobs: · 62 unmodified lines | |||
| 77 | VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} | 87 | VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} |
| 78 | 88 | ||
| 79 | - name: Deploy cloud | 89 | - name: Deploy cloud |
| 80 | run: bun run wrangler deploy -c dist/server/wrangler.json | 90 | run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }} |
| 81 | working-directory: apps/cloud | 91 | working-directory: apps/cloud |
| 82 | env: | 92 | env: |
| 83 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | 93 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 84 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | 94 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 85 | 95 | ||
| 96 | # Deploy marker: one event per deploy into the same Axiom dataset the | ||
| 97 | # worker traces land in, so a latency step-change lines up with its | ||
| 98 | # deploy in one query. Skipped (not failed) when the secret is absent, | ||
| 99 | # and never blocks the deploy. | ||
| 100 | - name: Record deploy marker in Axiom | ||
| 101 | env: | ||
| 102 | AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }} | ||
| 103 | run: | | ||
| 104 | if [ -z "$AXIOM_INGEST_TOKEN" ]; then | ||
| 105 | echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker" | ||
| 106 | exit 0 | ||
| 107 | fi | ||
| 108 | curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \ | ||
| 109 | -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \ | ||
| 110 | -H "Content-Type: application/json" \ | ||
| 111 | -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \ | ||
| 112 | || echo "deploy marker ingest failed (non-blocking)" | ||
| 113 | |||
| 86 | deploy-marketing: | 114 | deploy-marketing: |
| 87 | name: Deploy marketing | 115 | name: Deploy marketing |
| 88 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 116 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -0,0 +1,47 @@ | |||
| 1 | # Refresh the Microsoft Graph slice release assets. | ||
| 2 | # | ||
| 3 | # The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers | ||
| 4 | # isolate, so the runtime reads per-selection slices published on the | ||
| 5 | # `graph-slices` release tag (see packages/plugins/openapi/src/providers/ | ||
| 6 | # microsoft/slices.ts). This workflow rebuilds the slices from the current | ||
| 7 | # upstream spec on a schedule and on demand. | ||
| 8 | name: Graph slices | ||
| 9 | |||
| 10 | on: | ||
| 11 | schedule: | ||
| 12 | # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes | ||
| 13 | # on a similar cadence. A failed run leaves the previous assets serving. | ||
| 14 | - cron: "17 6 * * 1" | ||
| 15 | workflow_dispatch: {} | ||
| 16 | |||
| 17 | permissions: | ||
| 18 | contents: write | ||
| 19 | |||
| 20 | jobs: | ||
| 21 | slices: | ||
| 22 | runs-on: ubuntu-latest | ||
| 23 | timeout-minutes: 20 | ||
| 24 | steps: | ||
| 25 | - uses: actions/checkout@v4 | ||
| 26 | |||
| 27 | - uses: oven-sh/setup-bun@v2 | ||
| 28 | with: | ||
| 29 | bun-version: 1.3.11 | ||
| 30 | |||
| 31 | - name: Install dependencies | ||
| 32 | run: bun install --frozen-lockfile | ||
| 33 | |||
| 34 | - name: Generate slices | ||
| 35 | working-directory: packages/plugins/openapi | ||
| 36 | run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices" | ||
| 37 | |||
| 38 | - name: Publish to the graph-slices release | ||
| 39 | env: | ||
| 40 | GH_TOKEN: ${{ github.token }} | ||
| 41 | run: | | ||
| 42 | gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ | ||
| 43 | gh release create graph-slices --repo "$GITHUB_REPOSITORY" \ | ||
| 44 | --title "Microsoft Graph slices" --latest=false \ | ||
| 45 | --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow." | ||
| 46 | gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \ | ||
| 47 | --repo "$GITHUB_REPOSITORY" --clobber | ||
| @@ -10,6 +10,16 @@ concurrency: · 9 unmodified lines | |||
| 10 | group: pkg-pr-new-${{ github.event.pull_request.number }} | 10 | group: pkg-pr-new-${{ github.event.pull_request.number }} |
| 11 | cancel-in-progress: true | 11 | cancel-in-progress: true |
| 12 | 12 | ||
| 13 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 14 | # than the ones that look like they run product code: the first pass guessed, | ||
| 15 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 16 | # where the product is not executed, so the blanket application is the cheap | ||
| 17 | # structural answer. | ||
| 18 | env: | ||
| 19 | DO_NOT_TRACK: "1" | ||
| 20 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 21 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 22 | |||
| 13 | jobs: | 23 | jobs: |
| 14 | # Per-platform matrix: build the executor binary, tar it, upload to R2. | 24 | # Per-platform matrix: build the executor binary, tar it, upload to R2. |
| 15 | # The wrapper npm package is built later by the `publish` job which just | 25 | # The wrapper npm package is built later by the `publish` job which just |
| @@ -16,6 +16,16 @@ permissions: · 15 unmodified lines | |||
| 16 | contents: read | 16 | contents: read |
| 17 | pull-requests: read | 17 | pull-requests: read |
| 18 | 18 | ||
| 19 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 20 | # than the ones that look like they run product code: the first pass guessed, | ||
| 21 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 22 | # where the product is not executed, so the blanket application is the cheap | ||
| 23 | # structural answer. | ||
| 24 | env: | ||
| 25 | DO_NOT_TRACK: "1" | ||
| 26 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 27 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 28 | |||
| 19 | jobs: | 29 | jobs: |
| 20 | sweep: | 30 | sweep: |
| 21 | name: Destroy previews for closed PRs | 31 | name: Destroy previews for closed PRs |
| @@ -23,6 +23,16 @@ concurrency: · 22 unmodified lines | |||
| 23 | group: preview-${{ github.event.pull_request.number }} | 23 | group: preview-${{ github.event.pull_request.number }} |
| 24 | cancel-in-progress: ${{ github.event.action != 'closed' }} | 24 | cancel-in-progress: ${{ github.event.action != 'closed' }} |
| 25 | 25 | ||
| 26 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 27 | # than the ones that look like they run product code: the first pass guessed, | ||
| 28 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 29 | # where the product is not executed, so the blanket application is the cheap | ||
| 30 | # structural answer. | ||
| 31 | env: | ||
| 32 | DO_NOT_TRACK: "1" | ||
| 33 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 34 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 35 | |||
| 26 | jobs: | 36 | jobs: |
| 27 | deploy: | 37 | deploy: |
| 28 | name: Deploy preview | 38 | name: Deploy preview |
| @@ -21,6 +21,18 @@ concurrency: · 20 unmodified lines | |||
| 21 | group: publish-desktop-${{ github.ref }} | 21 | group: publish-desktop-${{ github.ref }} |
| 22 | cancel-in-progress: false | 22 | cancel-in-progress: false |
| 23 | 23 | ||
| 24 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 25 | # production PostHog project from every e2e shard: the analytics layer | ||
| 26 | # (integration_added, execution_completed, artifact_*) and the | ||
| 27 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 28 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 29 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 30 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 31 | env: | ||
| 32 | DO_NOT_TRACK: "1" | ||
| 33 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 34 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 35 | |||
| 24 | jobs: | 36 | jobs: |
| 25 | build: | 37 | build: |
| 26 | permissions: | 38 | permissions: |
| @@ -94,6 +106,17 @@ jobs: · 67 unmodified lines | |||
| 94 | - name: Install dependencies | 106 | - name: Install dependencies |
| 95 | run: bun install --frozen-lockfile | 107 | run: bun install --frozen-lockfile |
| 96 | 108 | ||
| 109 | # apps/desktop/build/ holds committed static inputs (icon, entitlements) | ||
| 110 | # that no module imports, so a sweep can delete them without breaking a | ||
| 111 | # build or a test. v1.6.1 was tagged without them: both mac legs ran ~8 | ||
| 112 | # minutes, then died inside codesign with "build/entitlements.mac.plist: | ||
| 113 | # cannot read entitlement data" against an arbitrary locale.pak, while | ||
| 114 | # the missing icon only ever produced a "default Electron icon is used" | ||
| 115 | # notice. Check the tag's own tree here so the failure names the cause. | ||
| 116 | - name: Check desktop build resources | ||
| 117 | run: bun ./scripts/check-build-resources.ts | ||
| 118 | working-directory: apps/desktop | ||
| 119 | |||
| 97 | - name: Build web app | 120 | - name: Build web app |
| 98 | run: bun run --filter @executor-js/local build | 121 | run: bun run --filter @executor-js/local build |
| 99 | 122 | ||
| @@ -143,8 +166,22 @@ jobs: · 43 unmodified lines | |||
| 143 | # → notarytool authentication for the notarization upload | 166 | # → notarytool authentication for the notarization upload |
| 144 | # When the secrets aren't set (forks, local), electron-builder | 167 | # When the secrets aren't set (forks, local), electron-builder |
| 145 | # silently produces an unsigned build. | 168 | # silently produces an unsigned build. |
| 146 | CSC_LINK: ${{ secrets.CSC_LINK }} | 169 | # |
| 147 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | 170 | # CSC_LINK / CSC_KEY_PASSWORD MUST stay scoped to the mac legs. |
| 171 | # Despite the name used here, CSC_LINK is electron-builder's | ||
| 172 | # cross-platform certificate variable: WinPackager falls back to it | ||
| 173 | # whenever WIN_CSC_LINK is unset, via | ||
| 174 | # platformPackager.getCscLink("WIN_CSC_LINK"). Handing the Apple | ||
| 175 | # Developer ID .p12 to the windows leg made electron-builder read its | ||
| 176 | # subject and write `publisherName: Developer ID Application: ...` | ||
| 177 | # into app-update.yml, so every Windows client rejected the update it | ||
| 178 | # had just downloaded with "New version is not signed by the | ||
| 179 | # application owner". Empty string is the documented "no certificate" | ||
| 180 | # value — WindowsSignToolManager treats `cscLink === ""` exactly like | ||
| 181 | # unset. The APPLE_* vars below need no such guard: they are only | ||
| 182 | # read by notarytool on darwin and are inert elsewhere. | ||
| 183 | CSC_LINK: ${{ matrix.platform == 'mac' && secrets.CSC_LINK || '' }} | ||
| 184 | CSC_KEY_PASSWORD: ${{ matrix.platform == 'mac' && secrets.CSC_KEY_PASSWORD || '' }} | ||
| 148 | APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }} | 185 | APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }} |
| 149 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} | 186 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} |
| 150 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | 187 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} |
| @@ -21,6 +21,16 @@ concurrency: · 20 unmodified lines | |||
| 21 | group: publish-executor-package-${{ github.ref }} | 21 | group: publish-executor-package-${{ github.ref }} |
| 22 | cancel-in-progress: false | 22 | cancel-in-progress: false |
| 23 | 23 | ||
| 24 | # CI is not a user — see the note in ci.yml. Applied to every workflow rather | ||
| 25 | # than the ones that look like they run product code: the first pass guessed, | ||
| 26 | # missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert | ||
| 27 | # where the product is not executed, so the blanket application is the cheap | ||
| 28 | # structural answer. | ||
| 29 | env: | ||
| 30 | DO_NOT_TRACK: "1" | ||
| 31 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 32 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 33 | |||
| 24 | jobs: | 34 | jobs: |
| 25 | publish: | 35 | publish: |
| 26 | runs-on: ubuntu-latest | 36 | runs-on: ubuntu-latest |
| @@ -62,7 +72,9 @@ jobs: · 35 unmodified lines | |||
| 62 | 72 | ||
| 63 | - name: Update npm for trusted publishing | 73 | - name: Update npm for trusted publishing |
| 64 | run: | | 74 | run: | |
| 65 | npm install -g npm@latest | 75 | # npm 12.0.0 fails `publish --provenance` with "Cannot find module |
| 76 | # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works. | ||
| 77 | npm install -g npm@11 | ||
| 66 | npm --version | 78 | npm --version |
| 67 | 79 | ||
| 68 | - name: Install dependencies | 80 | - name: Install dependencies |
| @@ -16,6 +16,18 @@ concurrency: · 15 unmodified lines | |||
| 16 | group: publish-selfhost-docker-${{ inputs.tag }} | 16 | group: publish-selfhost-docker-${{ inputs.tag }} |
| 17 | cancel-in-progress: false | 17 | cancel-in-progress: false |
| 18 | 18 | ||
| 19 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 20 | # production PostHog project from every e2e shard: the analytics layer | ||
| 21 | # (integration_added, execution_completed, artifact_*) and the | ||
| 22 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 23 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 24 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 25 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 26 | env: | ||
| 27 | DO_NOT_TRACK: "1" | ||
| 28 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 29 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 30 | |||
| 19 | jobs: | 31 | jobs: |
| 20 | metadata: | 32 | metadata: |
| 21 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 33 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| @@ -163,16 +175,124 @@ jobs: · 141 unmodified lines | |||
| 163 | if-no-files-found: error | 175 | if-no-files-found: error |
| 164 | retention-days: 1 | 176 | retention-days: 1 |
| 165 | 177 | ||
| 178 | # Release gate: run the full selfhost-docker e2e project against the exact | ||
| 179 | # by-digest image each build leg pushed, before any user-visible tag moves. | ||
| 180 | # Build-only smoke misses startup crashes and feature-path breaks (a package | ||
| 181 | # resolved at runtime but missing from the packaged runtime shipped broken in | ||
| 182 | # v1.5.30 exactly this way); per-arch matters too — the libsql native-addon | ||
| 183 | # bug was arm64-only. | ||
| 184 | e2e: | ||
| 185 | needs: | ||
| 186 | - metadata | ||
| 187 | - build | ||
| 188 | strategy: | ||
| 189 | fail-fast: false | ||
| 190 | matrix: | ||
| 191 | include: | ||
| 192 | - arch: amd64 | ||
| 193 | runner: blacksmith-4vcpu-ubuntu-2404 | ||
| 194 | - arch: arm64 | ||
| 195 | runner: blacksmith-4vcpu-ubuntu-2404-arm | ||
| 196 | runs-on: ${{ matrix.runner }} | ||
| 197 | timeout-minutes: 40 | ||
| 198 | permissions: | ||
| 199 | contents: read | ||
| 200 | packages: read | ||
| 201 | |||
| 202 | steps: | ||
| 203 | - name: Checkout release tag | ||
| 204 | uses: actions/checkout@v4 | ||
| 205 | with: | ||
| 206 | ref: ${{ needs.metadata.outputs.release_tag }} | ||
| 207 | persist-credentials: false | ||
| 208 | |||
| 209 | - uses: oven-sh/setup-bun@v2 | ||
| 210 | with: | ||
| 211 | bun-version: 1.3.11 | ||
| 212 | |||
| 213 | - name: Cache Bun package cache | ||
| 214 | uses: actions/cache@v4 | ||
| 215 | with: | ||
| 216 | path: ~/.bun/install/cache | ||
| 217 | key: ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11-${{ hashFiles('bun.lock') }} | ||
| 218 | restore-keys: | | ||
| 219 | ${{ runner.os }}-${{ runner.arch }}-bun-1.3.11- | ||
| 220 | |||
| 221 | # Scenario helpers spawn Node sidecars; pin the same Node 24 runtime the | ||
| 222 | # other workflows use. | ||
| 223 | - uses: actions/setup-node@v4 | ||
| 224 | with: | ||
| 225 | node-version: 24 | ||
| 226 | |||
| 227 | - run: bun install --frozen-lockfile | ||
| 228 | |||
| 229 | - name: Cache Playwright browsers | ||
| 230 | uses: actions/cache@v4 | ||
| 231 | with: | ||
| 232 | path: ~/.cache/ms-playwright | ||
| 233 | key: ${{ runner.os }}-${{ runner.arch }}-playwright-1.60.0 | ||
| 234 | restore-keys: | | ||
| 235 | ${{ runner.os }}-${{ runner.arch }}-playwright- | ||
| 236 | |||
| 237 | # Install from e2e so bunx resolves ITS pinned playwright (the version | ||
| 238 | # the tests run against) rather than floating to the latest. | ||
| 239 | - name: Install Playwright Chromium | ||
| 240 | run: bunx playwright install --with-deps chromium chromium-headless-shell | ||
| 241 | working-directory: e2e | ||
| 242 | |||
| 243 | - name: Log in to GHCR | ||
| 244 | uses: docker/login-action@v3 | ||
| 245 | with: | ||
| 246 | registry: ghcr.io | ||
| 247 | username: ${{ github.actor }} | ||
| 248 | password: ${{ secrets.GITHUB_TOKEN }} | ||
| 249 | |||
| 250 | - name: Download image digest | ||
| 251 | uses: actions/download-artifact@v4 | ||
| 252 | with: | ||
| 253 | name: digests-${{ matrix.arch }} | ||
| 254 | path: ${{ runner.temp }}/digests | ||
| 255 | |||
| 256 | - name: Resolve image reference | ||
| 257 | id: image_ref | ||
| 258 | shell: bash | ||
| 259 | env: | ||
| 260 | IMAGE: ${{ needs.metadata.outputs.image }} | ||
| 261 | run: | | ||
| 262 | set -euo pipefail | ||
| 263 | digest_file="$(find "$RUNNER_TEMP/digests" -maxdepth 1 -type f | head -n 1)" | ||
| 264 | if [ -z "$digest_file" ]; then | ||
| 265 | echo "No image digest was downloaded." | ||
| 266 | exit 1 | ||
| 267 | fi | ||
| 268 | ref="${IMAGE}@sha256:$(basename "$digest_file")" | ||
| 269 | echo "ref=$ref" >> "$GITHUB_OUTPUT" | ||
| 270 | docker pull "$ref" | ||
| 271 | |||
| 272 | - name: Run selfhost-docker scenarios against the published digest | ||
| 273 | env: | ||
| 274 | E2E_SELFHOST_DOCKER_IMAGE: ${{ steps.image_ref.outputs.ref }} | ||
| 275 | run: bunx vitest run --project selfhost-docker --retry=2 | ||
| 276 | working-directory: e2e | ||
| 277 | |||
| 278 | - name: Upload run artifacts | ||
| 279 | if: failure() | ||
| 280 | uses: actions/upload-artifact@v4 | ||
| 281 | with: | ||
| 282 | name: e2e-runs-selfhost-docker-${{ matrix.arch }} | ||
| 283 | path: | | ||
| 284 | e2e/runs/ | ||
| 285 | e2e/selfhost-docker.boot.log | ||
| 286 | retention-days: 7 | ||
| 287 | |||
| 166 | merge: | 288 | merge: |
| 167 | needs: | 289 | needs: |
| 168 | - metadata | 290 | - metadata |
| 169 | - build | 291 | - build |
| 292 | - e2e | ||
| 170 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 293 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 171 | permissions: | 294 | permissions: |
| 172 | packages: write | 295 | packages: write |
| 173 | env: | ||
| 174 | HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | ||
| 175 | |||
| 176 | steps: | 296 | steps: |
| 177 | - name: Download image digests | 297 | - name: Download image digests |
| 178 | uses: actions/download-artifact@v4 | 298 | uses: actions/download-artifact@v4 |
| @@ -219,10 +339,28 @@ jobs: · 40 unmodified lines | |||
| 219 | 339 | ||
| 220 | docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" | 340 | docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" |
| 221 | 341 | ||
| 342 | # Mirrors each release to the pre-org-move namespace | ||
| 343 | # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect. | ||
| 344 | # Deliberately a separate job: the canonical publish above never depends on | ||
| 345 | # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows | ||
| 346 | # as one red job pointing at exactly what to fix. | ||
| 347 | mirror-legacy: | ||
| 348 | needs: | ||
| 349 | - metadata | ||
| 350 | - merge | ||
| 351 | runs-on: blacksmith-4vcpu-ubuntu-2404 | ||
| 352 | env: | ||
| 353 | HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | ||
| 354 | |||
| 355 | steps: | ||
| 222 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) | 356 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) |
| 223 | if: env.HAS_LEGACY_TOKEN != 'true' | 357 | if: env.HAS_LEGACY_TOKEN != 'true' |
| 224 | run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." | 358 | run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." |
| 225 | 359 | ||
| 360 | - name: Set up Docker Buildx | ||
| 361 | if: env.HAS_LEGACY_TOKEN == 'true' | ||
| 362 | uses: docker/setup-buildx-action@v3 | ||
| 363 | |||
| 226 | - name: Log in to legacy GHCR namespace | 364 | - name: Log in to legacy GHCR namespace |
| 227 | if: env.HAS_LEGACY_TOKEN == 'true' | 365 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 228 | uses: docker/login-action@v3 | 366 | uses: docker/login-action@v3 |
| @@ -231,7 +369,7 @@ jobs: · 2 unmodified lines | |||
| 231 | username: rhyssullivan | 369 | username: rhyssullivan |
| 232 | password: ${{ secrets.GHCR_LEGACY_TOKEN }} | 370 | password: ${{ secrets.GHCR_LEGACY_TOKEN }} |
| 233 | 371 | ||
| 234 | - name: Mirror self-host image to legacy GHCR namespace | 372 | - name: Mirror release tags to the legacy namespace |
| 235 | if: env.HAS_LEGACY_TOKEN == 'true' | 373 | if: env.HAS_LEGACY_TOKEN == 'true' |
| 236 | shell: bash | 374 | shell: bash |
| 237 | env: | 375 | env: |
| @@ -13,6 +13,18 @@ concurrency: · 12 unmodified lines | |||
| 13 | group: release-${{ github.ref }} | 13 | group: release-${{ github.ref }} |
| 14 | cancel-in-progress: false | 14 | cancel-in-progress: false |
| 15 | 15 | ||
| 16 | # CI is not a user. Without this, executor's own product telemetry reports to the | ||
| 17 | # production PostHog project from every e2e shard: the analytics layer | ||
| 18 | # (integration_added, execution_completed, artifact_*) and the | ||
| 19 | # integrations-registry fetch, which lands as a `hit` event. Each shard is a | ||
| 20 | # fresh container, so it mints a fresh anonymous id and arrives on a fresh | ||
| 21 | # runner IP — inflating machine and user counts by roughly an order of | ||
| 22 | # magnitude. Both opt-outs are the packages' documented CI/test hooks. | ||
| 23 | env: | ||
| 24 | DO_NOT_TRACK: "1" | ||
| 25 | EXECUTOR_DISABLE_ANALYTICS: "1" | ||
| 26 | EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" | ||
| 27 | |||
| 16 | jobs: | 28 | jobs: |
| 17 | release: | 29 | release: |
| 18 | runs-on: ubuntu-latest | 30 | runs-on: ubuntu-latest |
| @@ -42,7 +54,9 @@ jobs: · 23 unmodified lines | |||
| 42 | 54 | ||
| 43 | - name: Update npm for trusted publishing | 55 | - name: Update npm for trusted publishing |
| 44 | run: | | 56 | run: | |
| 45 | npm install -g npm@latest | 57 | # npm 12.0.0 fails `publish --provenance` with "Cannot find module |
| 58 | # 'sigstore'" (npm/cli#9722); 11.x supports trusted publishing and works. | ||
| 59 | npm install -g npm@11 | ||
| 46 | npm --version | 60 | npm --version |
| 47 | 61 | ||
| 48 | - name: Install dependencies | 62 | - name: Install dependencies |
| @@ -13,6 +13,7 @@ · 12 unmodified lines | |||
| 13 | "*.tsbuildinfo", | 13 | "*.tsbuildinfo", |
| 14 | "executor-*.tgz", | 14 | "executor-*.tgz", |
| 15 | "**/routeTree.gen.ts", | 15 | "**/routeTree.gen.ts", |
| 16 | "**/smoke-harness-bundle.gen.ts", | ||
| 16 | "apps/cloud/src/services/executor-schema.ts" | 17 | "apps/cloud/src/services/executor-schema.ts" |
| 17 | ] | 18 | ] |
| 18 | } | 19 | } |
| @@ -64,6 +64,7 @@ · 63 unmodified lines | |||
| 64 | "apps/desktop/src/main.ts", | 64 | "apps/desktop/src/main.ts", |
| 65 | "scripts/**/*.{ts,js}", | 65 | "scripts/**/*.{ts,js}", |
| 66 | "apps/*/scripts/**/*.{ts,js}", | 66 | "apps/*/scripts/**/*.{ts,js}", |
| 67 | "packages/*/*/scripts/**/*.{ts,js}", | ||
| 67 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", | 68 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", |
| 68 | ], | 69 | ], |
| 69 | "rules": { | 70 | "rules": { |
| @@ -119,6 +120,31 @@ · 49 unmodified lines | |||
| 119 | "executor/no-try-catch-or-throw": "off", | 120 | "executor/no-try-catch-or-throw": "off", |
| 120 | }, | 121 | }, |
| 121 | }, | 122 | }, |
| 123 | { | ||
| 124 | // boundary: the MCP-Apps shell is browser code, not Effect domain code. | ||
| 125 | // It runs inside a sandboxed iframe and talks to the host over | ||
| 126 | // postMessage and the MCP bridge — both of which are throw/Promise APIs | ||
| 127 | // with no Effect runtime present. `new Function` evaluation of generated | ||
| 128 | // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire | ||
| 129 | // all surface untyped JS errors that must be caught and rendered rather | ||
| 130 | // than modelled. Scoped to the iframe payload plus its build config; the | ||
| 131 | // package's Effect-facing loader (`src/shell-html.ts`) is deliberately | ||
| 132 | // NOT listed and uses inline suppressions instead. | ||
| 133 | "files": [ | ||
| 134 | "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", | ||
| 135 | "packages/hosts/mcp-apps-shell/vite.config.shell.ts", | ||
| 136 | ], | ||
| 137 | "rules": { | ||
| 138 | "executor/no-double-cast": "off", | ||
| 139 | "executor/no-error-constructor": "off", | ||
| 140 | "executor/no-instanceof-error": "off", | ||
| 141 | "executor/no-json-parse": "off", | ||
| 142 | "executor/no-promise-catch": "off", | ||
| 143 | "executor/no-promise-reject": "off", | ||
| 144 | "executor/no-try-catch-or-throw": "off", | ||
| 145 | "executor/no-unknown-error-message": "off", | ||
| 146 | }, | ||
| 147 | }, | ||
| 122 | { | 148 | { |
| 123 | "files": ["packages/kernel/core/src/code-recovery.ts"], | 149 | "files": ["packages/kernel/core/src/code-recovery.ts"], |
| 124 | "rules": { | 150 | "rules": { |
| @@ -1,83 +1,58 @@ | |||
| 1 | # AGENTS.md | 1 | # Executor policy |
| 2 | 2 | ||
| 3 | Principles: the contracts that stay true while implementations churn. For how to | 3 | Stable contracts live here. Current setup and server mechanics are in |
| 4 | run, boot, share, or navigate things today, see [RUNNING.md](RUNNING.md); for | 4 | [RUNNING.md](RUNNING.md); e2e invariants are in |
| 5 | e2e scenarios, [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` first in | 5 | [e2e/AGENTS.md](e2e/AGENTS.md). Run `bun run bootstrap` in every fresh rift. |
| 6 | any fresh checkout or worktree. | ||
| 7 | 6 | ||
| 8 | ## Task Completion | 7 | ## Verification and evidence |
| 9 | 8 | ||
| 10 | - Tests use Effect Vitest. Run scoped tests with `vitest run ...`. `bun run test` | 9 | - Tests use Effect Vitest. Run scoped tests with `vitest run ...` or the |
| 11 | is fine (it delegates to Vitest); NEVER run `bun test`. | 10 | package script. Never use `bun test`. |
| 12 | - Run the narrowest useful verification for a change. For broad or merge-ready | 11 | - Use the narrowest meaningful verification while iterating. Merge-ready gates |
| 13 | work the full gates are `bun run format:check`, `lint`, `typecheck`, `test`. | 12 | are `bun run format:check`, `lint`, `typecheck`, and `test`. |
| 14 | - Run `bun run format` before opening a PR, staging only files your branch | 13 | - e2e is slow. Avoid running it often during development: optimize for |
| 15 | touched (leave unrelated files `format` rewrites out of the PR). | 14 | iteration speed first, then test. Save e2e for when the change is settled, |
| 15 | and run the narrowest scenario rather than the full suite. | ||
| 16 | - Run `bun run format` before a PR and include only files owned by the branch. | ||
| 17 | - User-visible work requires a specific e2e run, a browsable dev instance, and | ||
| 18 | the recording or trace showing what to inspect. Add a scenario when none | ||
| 19 | covers the changed behavior. | ||
| 16 | 20 | ||
| 17 | ## Handing Back Work: Evidence, Not Assertions | 21 | ## Service emulators |
| 18 | 22 | ||
| 19 | "Done" is something the user can open, not a claim. When work changes what a user | 23 | Tests and demos that need an upstream API, OAuth/OIDC provider, or webhook use |
| 20 | sees, hand back: an e2e run that proves it (link the specific run with one line | 24 | the published `@executor-js/emulate` emulators rather than stubs. They provide |
| 21 | on what to look at, not a wall of green), the dev server left running so they can | 25 | wire-level state, real-shaped credentials, OpenAPI descriptions, and request |
| 22 | poke at it, and the paths worth trying by hand including ones no scenario covers | 26 | ledgers. Create per-run hosted instances through the service's |
| 23 | yet. If no scenario covers the change, write one, and embed its recording in the | 27 | `/_emulate/instances` control route. |
| 24 | PR. The machinery runs in reverse too: seed an environment into a state | ||
| 25 | (reproduce a bug live, stage data) and hand across the link. | ||
| 26 | 28 | ||
| 27 | ## Service Emulators | 29 | Emulate is a separate project. Make emulator changes there and consume the |
| 30 | published package here; never re-vendor or create a parallel fake inside | ||
| 31 | executor. | ||
| 28 | 32 | ||
| 29 | For any test or demo needing an upstream API, OAuth/OIDC provider, or webhook | 33 | ## Engineering boundaries |
| 30 | source, use the `@executor-js/emulate` emulators instead of writing a stub: | ||
| 31 | wire-level and stateful, real SDKs run unmodified, each serves a full OpenAPI | ||
| 32 | spec, mints real-shaped credentials, and records every call in a request ledger | ||
| 33 | to assert against. Hosted at `https://<service>.emulators.dev`. See the `emulate` | ||
| 34 | skill (`.claude/skills/emulate/SKILL.md`). They are a standalone project | ||
| 35 | (`github.com/UsefulSoftwareCo/emulate`) consumed here as the published package: | ||
| 36 | full autonomy to change, publish, and deploy them on their `main`; don't re-vendor. | ||
| 37 | |||
| 38 | ## Attribution | ||
| 39 | |||
| 40 | No AI/Claude/Anthropic/Co-Authored-By attribution in commits, messages, PRs, or | ||
| 41 | generated files. PR titles and descriptions go to a public repo: no internal info | ||
| 42 | or specific names unless explicitly stated. | ||
| 43 | |||
| 44 | ## Collaboration Notes | ||
| 45 | |||
| 46 | - The user uses speech-to-text; infer likely intent from odd wording, ask only | ||
| 47 | when needed. | ||
| 48 | - Code is cheap to write: no time estimates, implementation time isn't a blocker. | ||
| 49 | - Never use em-dashes anywhere. Use commas, colons, parentheses, or separate | ||
| 50 | sentences. | ||
| 51 | |||
| 52 | ## Reference Repos | ||
| 53 | |||
| 54 | Repos in `.reference` (Effect, effect-atom, …) are available for patterns. Clone | ||
| 55 | a given Git URL into `.reference` and pull latest before using it. | ||
| 56 | |||
| 57 | ## Engineering Priorities | ||
| 58 | 34 | ||
| 59 | - Prefer correctness and predictable behavior over short-term convenience. | 35 | - Prefer correctness and predictable behavior over short-term convenience. |
| 60 | - Preserve runtime behavior when changing lint, typing, or test structure. | 36 | - Preserve runtime behavior during lint, typing, and test-structure changes. |
| 61 | - Keep package boundaries clear; use public package exports, not cross-package | 37 | - Use public package exports; never cross package boundaries with relative |
| 62 | relative imports. | 38 | imports. |
| 63 | - Extract shared logic only when the shared behavior is real; avoid broad generic | 39 | - Extract shared logic only for genuinely shared behavior. Avoid generic |
| 64 | abstractions for one-off duplication. | 40 | abstractions for one-off duplication. |
| 41 | - Public PRs, commits, generated files, and documentation contain no private | ||
| 42 | names, internal context, customer-derived data, or AI attribution. | ||
| 65 | 43 | ||
| 66 | ## Package Roles | 44 | ## Package ownership |
| 67 | 45 | ||
| 68 | - `packages/core/sdk`: core contracts, plugin wiring, scopes, sources, secrets, | 46 | - `packages/core/sdk`: contracts, plugin wiring, scopes, integrations, secrets, |
| 69 | policies, fixtures. `@executor-js/sdk/http-auth` carries the shared auth-method | 47 | policies, and fixtures. Shared HTTP auth vocabulary lives in |
| 70 | vocabulary the HTTP protocol plugins compose (core never imports it, keeping it | 48 | `@executor-js/sdk/http-auth`; core remains carrier-agnostic. |
| 71 | carrier-agnostic). | ||
| 72 | - `packages/core/storage-*`: storage adapters and test support. | 49 | - `packages/core/storage-*`: storage adapters and test support. |
| 73 | - `packages/plugins/*`: protocol and provider plugins; their runtime, React, API, | 50 | - `packages/plugins/*`: protocol/provider implementations and their runtime, |
| 74 | and testing helpers live with the owning plugin. | 51 | React, API, and testing helpers. |
| 75 | - `packages/react`: shared React UI and atom/client integration. | 52 | - `packages/react`: shared React UI and client/atom integration. |
| 76 | - `packages/hosts/mcp`: MCP host surface. | 53 | - `packages/hosts/mcp`: MCP host surface. |
| 77 | - `packages/kernel/*`: execution runtimes and code execution substrate. | 54 | - `packages/kernel/*`: execution runtimes and code-execution substrate. |
| 78 | - `apps/{local,cloud,cli,desktop}`: product entry points composing the packages. | 55 | - `apps/{local,cloud,cli,desktop}`: product composition roots. |
| 79 | |||
| 80 | ## Other | ||
| 81 | 56 | ||
| 82 | Note mistakes in MISTAKES.md, missing context or tools in DESIRES.md, and env | 57 | Record mistakes in `MISTAKES.md`, missing capabilities in `DESIRES.md`, and |
| 83 | learnings in LEARNINGS.md. | 58 | environment discoveries in `LEARNINGS.md`. |
| @@ -8,7 +8,7 @@ and per-tool policies, then use that one catalog from any MCP-compatible agent. · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) | 9 | [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) |
| 10 | 10 | ||
| 11 | [https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad) | 11 |  |
| 12 | 12 | ||
| 13 | ## Why Executor | 13 | ## Why Executor |
| 14 | 14 | ||
| @@ -21,9 +21,9 @@ set its policy once, and every agent shares it over MCP. Your integrations, · 6 unmodified lines | |||
| 21 | auth, and policies live in one place instead of being scattered across each | 21 | auth, and policies live in one place instead of being scattered across each |
| 22 | client. | 22 | client. |
| 23 | 23 | ||
| 24 | - **Any source.** First-party support for MCP servers, OpenAPI, GraphQL, and | 24 | - **Any integration.** First-party support for MCP servers, OpenAPI, GraphQL, and |
| 25 | Google Discovery. If you can describe it with a JSON schema, it can be an | 25 | Google Discovery. If you can describe it with a JSON schema, it can be an |
| 26 | integration. The plugin system is open to any source type. | 26 | integration. The plugin system is open to any integration type. |
| 27 | - **One catalog, every agent.** Anything MCP-compatible connects to the same | 27 | - **One catalog, every agent.** Anything MCP-compatible connects to the same |
| 28 | set of tools. | 28 | set of tools. |
| 29 | - **Governed by policy.** Each tool is allowed, gated behind approval, or | 29 | - **Governed by policy.** Each tool is allowed, gated behind approval, or |
| @@ -100,12 +100,12 @@ appear. · 70 unmodified lines | |||
| 100 | 100 | ||
| 101 | ## Add an integration | 101 | ## Add an integration |
| 102 | 102 | ||
| 103 | From the web UI, click **Add Source**, paste an OpenAPI, GraphQL, or MCP URL, | 103 | From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL, |
| 104 | and Executor detects the type, indexes the tools, and handles auth. Or add one | 104 | and Executor detects the type, indexes the tools, and handles auth. Or add one |
| 105 | from the CLI: | 105 | from the CLI: |
| 106 | 106 | ||
| 107 | ```bash | 107 | ```bash |
| 108 | executor call executor openapi addSource '{ | 108 | executor call executor openapi addIntegration '{ |
| 109 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", | 109 | "spec": "https://petstore3.swagger.io/api/v3/openapi.json", |
| 110 | "namespace": "petstore", | 110 | "namespace": "petstore", |
| 111 | "baseUrl": "https://petstore3.swagger.io/api/v3" | 111 | "baseUrl": "https://petstore3.swagger.io/api/v3" |
| @@ -113,7 +113,7 @@ executor call executor openapi addSource '{ · 1 unmodified line | |||
| 113 | ``` | 113 | ``` |
| 114 | 114 | ||
| 115 | Use `baseUrl` when the OpenAPI document has relative `servers` entries (for | 115 | Use `baseUrl` when the OpenAPI document has relative `servers` entries (for |
| 116 | example `"/api/v3"`). Confirm it is live with `executor tools sources`. | 116 | example `"/api/v3"`). Confirm it is live with `executor tools integrations`. |
| 117 | 117 | ||
| 118 | ## Using tools | 118 | ## Using tools |
| 119 | 119 | ||
| @@ -169,7 +169,7 @@ executor call <path...> --help # browse namespaces/resources/methods · 49 unmodified lines | |||
| 169 | executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces | 169 | executor call <path...> --help --match "<text>" --limit <n> # narrow huge namespaces |
| 170 | executor resume --execution-id <id> # resume paused execution | 170 | executor resume --execution-id <id> # resume paused execution |
| 171 | executor tools search "<query>" # search tools by intent | 171 | executor tools search "<query>" # search tools by intent |
| 172 | executor tools sources # list configured sources + tool counts | 172 | executor tools integrations # list configured integrations + tool counts |
| 173 | executor tools describe <path> # show tool TypeScript/JSON schema | 173 | executor tools describe <path> # show tool TypeScript/JSON schema |
| 174 | ``` | 174 | ``` |
| 175 | 175 | ||
| @@ -190,7 +190,7 @@ apps/ · 14 unmodified lines | |||
| 190 | packages/ | 190 | packages/ |
| 191 | core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core | 191 | core/ contracts, plugin wiring, scopes, policies, SDK, API, CLI core |
| 192 | kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker) | 192 | kernel/ execution runtimes (QuickJS, Deno subprocess, dynamic worker) |
| 193 | plugins/ source and provider plugins (openapi, graphql, mcp, google, | 193 | plugins/ integration and provider plugins (openapi, graphql, mcp, google, |
| 194 | microsoft, 1password, keychain, encrypted/file secrets, ...) | 194 | microsoft, 1password, keychain, encrypted/file secrets, ...) |
| 195 | hosts/ host adapters (MCP surface, Cloudflare) | 195 | hosts/ host adapters (MCP surface, Cloudflare) |
| 196 | react/ shared React UI | 196 | react/ shared React UI |
| @@ -0,0 +1,101 @@ | |||
| 1 | # Telemetry | ||
| 2 | |||
| 3 | Executor's local products (the CLI, the desktop app) and self-hosted | ||
| 4 | deployments send a small set of anonymous usage events. This document is the | ||
| 5 | complete account of what is sent, what is deliberately not sent, why the | ||
| 6 | feature exists, and how to turn it off. | ||
| 7 | |||
| 8 | The hosted cloud product has its own browser-side analytics, disclosed | ||
| 9 | separately in its terms; this document covers the software that runs on your | ||
| 10 | machines. | ||
| 11 | |||
| 12 | ## Why | ||
| 13 | |||
| 14 | Executor is used mostly outside our infrastructure. Without some signal from | ||
| 15 | local and self-hosted installs, every product decision about them is a guess: | ||
| 16 | we cannot tell whether a feature ships broken, whether anyone uses toolkits, | ||
| 17 | whether executions fail at unusual rates after a release, or whether the | ||
| 18 | product is growing anywhere except cloud. | ||
| 19 | |||
| 20 | The events exist to answer exactly one kind of question: **how are product | ||
| 21 | features being used?** They are metadata about the product, not about you. | ||
| 22 | Anything that would answer "what is this user doing" — which APIs you call, | ||
| 23 | what your tools are named, what your code does — is out of scope by design, | ||
| 24 | not by omission. | ||
| 25 | |||
| 26 | ## What is sent | ||
| 27 | |||
| 28 | Each event carries its named properties plus, on every event: a random | ||
| 29 | per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the | ||
| 30 | release channel, and the app version. | ||
| 31 | |||
| 32 | | Event | Properties | Meaning | | ||
| 33 | | --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 34 | | `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. | | ||
| 35 | | `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). | | ||
| 36 | | `integration_removed` | `plugin_key` | An integration was removed, by kind. | | ||
| 37 | | `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. | | ||
| 38 | | `artifact_viewed` | `via` | An artifact was opened for its content. | | ||
| 39 | | `artifact_updated` | `via` | An artifact was overwritten or renamed. | | ||
| 40 | | `artifact_deleted` | `via` | An artifact was deleted. | | ||
| 41 | |||
| 42 | That table is exhaustive. The typed catalog the code compiles against is | ||
| 43 | [`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts); | ||
| 44 | an event that is not in that file cannot be sent. | ||
| 45 | |||
| 46 | ## What is never sent | ||
| 47 | |||
| 48 | - No code, tool arguments, tool results, or error messages. | ||
| 49 | - No secrets, tokens, or credentials. | ||
| 50 | - No names you typed: no integration slugs, connection names, toolkit slugs, | ||
| 51 | tool names, or artifact titles. The **kind** of integration (`openapi`, | ||
| 52 | `mcp`, ...) is a product question; **which** service you connected it to is | ||
| 53 | your business. | ||
| 54 | - No identity: no emails, usernames, hostnames, IP-derived location, or org | ||
| 55 | names. Events are marked so the analytics backend builds no person profile. | ||
| 56 | |||
| 57 | ## The anonymous id | ||
| 58 | |||
| 59 | A random UUID is minted the first time the daemon or server starts and stored | ||
| 60 | as `analytics-id` in the data directory (`~/.executor` for local installs, the | ||
| 61 | configured data dir for self-host). It exists so that ten events from one | ||
| 62 | install count as one install, not ten. It is not derived from your machine, | ||
| 63 | account, or network, and deleting the file resets it. When telemetry is | ||
| 64 | disabled the file is never created. | ||
| 65 | |||
| 66 | ## Opting out | ||
| 67 | |||
| 68 | Set either environment variable to `1`, `true`, or `yes`: | ||
| 69 | |||
| 70 | - `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com), | ||
| 71 | which Executor also honors for its other outbound calls (crash reporting, | ||
| 72 | the integrations.sh catalog fetch). | ||
| 73 | - `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog | ||
| 74 | fetch and crash reporting to keep working. | ||
| 75 | |||
| 76 | Opting out is total: the analytics service becomes a no-op, nothing is | ||
| 77 | buffered, nothing is sent, and no id file is written. The CLI's managed | ||
| 78 | service (launchd/systemd) forwards both variables into the supervised | ||
| 79 | daemon's environment, so an opted-out install stays opted out. | ||
| 80 | |||
| 81 | ## Delivery mechanics | ||
| 82 | |||
| 83 | Events buffer in memory (bounded) and flush in batches on a fixed cadence, | ||
| 84 | plus once at shutdown. Delivery is best-effort: a failed flush re-queues and | ||
| 85 | retries later, failures are swallowed, and no user-facing operation ever | ||
| 86 | waits on — or can be failed by — analytics. The ingest endpoint is PostHog | ||
| 87 | (`us.i.posthog.com`); the project key in the source identifies the project | ||
| 88 | and grants no read access. | ||
| 89 | |||
| 90 | ## Where the line is enforced | ||
| 91 | |||
| 92 | Structurally, not by convention: | ||
| 93 | |||
| 94 | - The event catalog is a closed, typed interface — adding a property means | ||
| 95 | editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed | ||
| 96 | change, next to the property rules at the top of that file. | ||
| 97 | - Attribution (`plane`, `via`) is bound where each serving surface is | ||
| 98 | composed, so events cannot carry request-controlled labels. | ||
| 99 | - Observers hang off neutral seams (an engine wrapper, post-commit hooks); the | ||
| 100 | core SDK carries no analytics vocabulary and hosts that do not opt in — like | ||
| 101 | every test — compose with no analytics at all. | ||
| @@ -1,5 +1,352 @@ | |||
| 1 | # executor | 1 | # executor |
| 2 | 2 | ||
| 3 | ## 1.6.2 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - [#1802](https://github.com/UsefulSoftwareCo/executor/pull/1802) [`2afb6e8`](https://github.com/UsefulSoftwareCo/executor/commit/2afb6e86ddf081afb8a246d76291a7ae668e05f4) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Concurrent API requests no longer share one database provider build** | ||
| 8 | |||
| 9 | Every HTTP request gets its own database connection, opened when the request fiber's scope opens and closed when it closes. The middleware that builds a request's execution stack, however, captures the boot fiber's context once at layer-construction time and re-applies it to every request. A captured context carries Effect's current memoization map, and re-applying it replaced the fresh per-request map with the boot one — which every in-flight request in the isolate shares. | ||
| 10 | |||
| 11 | The per-request stack build then memoized itself there. Sequential requests still rebuilt, because the memo entry is released once the request that built it finishes, so the problem was confined to requests that overlap: the second request reused the first one's stack build, and with it the first one's database connection. A request could therefore issue queries on a connection it did not own, and lose that connection mid-flight when the owner finished and closed it — typically surfacing as a failed read after a slow outbound call, on a request that had already read successfully. | ||
| 12 | |||
| 13 | The stack is now built with a request-local memoization scope, so overlapping requests each build their own stack over their own connection. The captured context still carries the long-lived services it exists to carry. The two other per-request provider builds that ran under a captured context — the account provider and the admin-users provider — are built the same way, for the same reason. | ||
| 14 | |||
| 15 | - [#1807](https://github.com/UsefulSoftwareCo/executor/pull/1807) [`78311a1`](https://github.com/UsefulSoftwareCo/executor/commit/78311a1ce506705a2420239fd96823203a9a1374) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Desktop: restore the macOS signing entitlements and app icon that were accidentally removed from the build inputs, and fail fast at PR time and before publishing when any configured build resource is missing. The v1.6.1 desktop build could not be signed; this release supersedes it. | ||
| 16 | |||
| 17 | - Updated dependencies []: | ||
| 18 | - @executor-js/sdk@1.6.2 | ||
| 19 | - @executor-js/runtime-quickjs@1.6.2 | ||
| 20 | - @executor-js/local@1.6.2 | ||
| 21 | - @executor-js/api@1.4.65 | ||
| 22 | |||
| 23 | ## 1.6.1 | ||
| 24 | |||
| 25 | ### Patch Changes | ||
| 26 | |||
| 27 | - [#1576](https://github.com/UsefulSoftwareCo/executor/pull/1576) [`6535a74`](https://github.com/UsefulSoftwareCo/executor/commit/6535a74de4860e376e5a2e273cb2d5a662f98c5c) Thanks [@GeiserX](https://github.com/GeiserX)! - **The CLI's server-connection store is written owner-only, and two of its tests now actually run** | ||
| 28 | |||
| 29 | `~/.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`. | ||
| 30 | |||
| 31 | 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. | ||
| 32 | |||
| 33 | 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. | ||
| 34 | |||
| 35 | - [#1454](https://github.com/UsefulSoftwareCo/executor/pull/1454) [`e8ea62c`](https://github.com/UsefulSoftwareCo/executor/commit/e8ea62c8ba295931cf969cd310278b6a50771bdc) Thanks [@jadch](https://github.com/jadch)! - Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first. | ||
| 36 | |||
| 37 | - [#1579](https://github.com/UsefulSoftwareCo/executor/pull/1579) [`31e17c7`](https://github.com/UsefulSoftwareCo/executor/commit/31e17c725f0987f86896519f0626f9c94d39ccb7) Thanks [@GeiserX](https://github.com/GeiserX)! - **The desktop settings store is written owner-only** | ||
| 38 | |||
| 39 | `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. | ||
| 40 | |||
| 41 | 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. | ||
| 42 | |||
| 43 | - [#1582](https://github.com/UsefulSoftwareCo/executor/pull/1582) [`e8e97c4`](https://github.com/UsefulSoftwareCo/executor/commit/e8e97c4c008fce702ee07ec0ffe2a8d3e6946a21) Thanks [@GeiserX](https://github.com/GeiserX)! - **An MCP health check no longer reports `healthy` when the connection's credential is missing** | ||
| 44 | |||
| 45 | 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. | ||
| 46 | |||
| 47 | 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. | ||
| 48 | |||
| 49 | 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. | ||
| 50 | |||
| 51 | - [#1741](https://github.com/UsefulSoftwareCo/executor/pull/1741) [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Opt-in per-integration search tools on the MCP surface** | ||
| 52 | |||
| 53 | 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. | ||
| 54 | |||
| 55 | - [#1573](https://github.com/UsefulSoftwareCo/executor/pull/1573) [`45ba141`](https://github.com/UsefulSoftwareCo/executor/commit/45ba1419b4e33ad10b0856c5fbc40fabc8f6efe1) Thanks [@GeiserX](https://github.com/GeiserX)! - **The MCP connection pool no longer keeps credentials in its cache key** | ||
| 56 | |||
| 57 | 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. | ||
| 58 | |||
| 59 | 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. | ||
| 60 | |||
| 61 | Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes. | ||
| 62 | |||
| 63 | - [#1800](https://github.com/UsefulSoftwareCo/executor/pull/1800) [`eac13e7`](https://github.com/UsefulSoftwareCo/executor/commit/eac13e7032c027d866c06728f1d5b634fd9ef7dd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Idle MCP session runtimes are actually reclaimed** | ||
| 64 | |||
| 65 | 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. | ||
| 66 | |||
| 67 | 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. | ||
| 68 | |||
| 69 | 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. | ||
| 70 | |||
| 71 | 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. | ||
| 72 | |||
| 73 | 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. | ||
| 74 | |||
| 75 | - [#1609](https://github.com/UsefulSoftwareCo/executor/pull/1609) [`662ebe2`](https://github.com/UsefulSoftwareCo/executor/commit/662ebe28d5109156a1aa3b27eaa80ba9daacaf11) Thanks [@timkley](https://github.com/timkley)! - **Fix: declare the OAuth application type during dynamic client registration** | ||
| 76 | |||
| 77 | Dynamic OAuth registrations now identify HTTPS callbacks as web applications | ||
| 78 | and loopback HTTP callbacks as native applications. This lets strict OAuth | ||
| 79 | servers validate Executor's redirect URI against the correct client type. | ||
| 80 | |||
| 81 | - [#1570](https://github.com/UsefulSoftwareCo/executor/pull/1570) [`e66a3d8`](https://github.com/UsefulSoftwareCo/executor/commit/e66a3d893c948fcccc7fc028870d051cb2d31860) Thanks [@GeiserX](https://github.com/GeiserX)! - **The OAuth popup clears its result out of `localStorage` after handing it over** | ||
| 82 | |||
| 83 | 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. | ||
| 84 | |||
| 85 | 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. | ||
| 86 | |||
| 87 | - [#1574](https://github.com/UsefulSoftwareCo/executor/pull/1574) [`c8bb857`](https://github.com/UsefulSoftwareCo/executor/commit/c8bb8578c3afdc9731274d9e9abc68d7ce32d9ab) Thanks [@GeiserX](https://github.com/GeiserX)! - **The 1Password service-account token is cleared from the op-js global after each call** | ||
| 88 | |||
| 89 | `@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. | ||
| 90 | |||
| 91 | 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. | ||
| 92 | |||
| 93 | 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. | ||
| 94 | |||
| 95 | - [#1749](https://github.com/UsefulSoftwareCo/executor/pull/1749) [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - 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`. | ||
| 96 | |||
| 97 | - [#1798](https://github.com/UsefulSoftwareCo/executor/pull/1798) [`69b0e64`](https://github.com/UsefulSoftwareCo/executor/commit/69b0e6413737599f11848b3877048f789ca84ac5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **The packed Worker toolchain is verified at build time, and an incomplete copy is now reported instead of silently ignored** | ||
| 98 | |||
| 99 | `@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. | ||
| 100 | |||
| 101 | 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. | ||
| 102 | |||
| 103 | 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. | ||
| 104 | |||
| 105 | - Updated dependencies [[`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: | ||
| 106 | - @executor-js/sdk@1.6.1 | ||
| 107 | - @executor-js/api@1.4.64 | ||
| 108 | - @executor-js/local@1.6.1 | ||
| 109 | - @executor-js/runtime-quickjs@1.6.1 | ||
| 110 | |||
| 111 | ## 1.6.0 | ||
| 112 | |||
| 113 | ### Patch Changes | ||
| 114 | |||
| 115 | - [#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. | ||
| 116 | |||
| 117 | - Updated dependencies [[`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49)]: | ||
| 118 | - @executor-js/sdk@1.6.0 | ||
| 119 | - @executor-js/local@1.6.0 | ||
| 120 | - @executor-js/api@1.4.63 | ||
| 121 | - @executor-js/runtime-quickjs@1.6.0 | ||
| 122 | |||
| 123 | ## 1.5.42 | ||
| 124 | |||
| 125 | ### Patch Changes | ||
| 126 | |||
| 127 | - Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: | ||
| 128 | - @executor-js/sdk@1.5.42 | ||
| 129 | - @executor-js/local@1.5.42 | ||
| 130 | - @executor-js/api@1.4.62 | ||
| 131 | - @executor-js/runtime-quickjs@1.5.42 | ||
| 132 | |||
| 133 | ## 1.5.41 | ||
| 134 | |||
| 135 | ### Patch Changes | ||
| 136 | |||
| 137 | - [#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** | ||
| 138 | |||
| 139 | `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. | ||
| 140 | |||
| 141 | 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. | ||
| 142 | |||
| 143 | - [#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** | ||
| 144 | |||
| 145 | 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. | ||
| 146 | |||
| 147 | - [#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** | ||
| 148 | |||
| 149 | 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. | ||
| 150 | |||
| 151 | 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. | ||
| 152 | |||
| 153 | 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. | ||
| 154 | |||
| 155 | - Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: | ||
| 156 | - @executor-js/sdk@1.5.41 | ||
| 157 | - @executor-js/local@1.5.41 | ||
| 158 | - @executor-js/api@1.4.61 | ||
| 159 | - @executor-js/runtime-quickjs@1.5.41 | ||
| 160 | |||
| 161 | ## 1.5.40 | ||
| 162 | |||
| 163 | ### Patch Changes | ||
| 164 | |||
| 165 | - [#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. | ||
| 166 | |||
| 167 | - [#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** | ||
| 168 | |||
| 169 | 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. | ||
| 170 | |||
| 171 | - [#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. | ||
| 172 | |||
| 173 | - Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a), [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336)]: | ||
| 174 | - @executor-js/sdk@1.5.40 | ||
| 175 | - @executor-js/local@1.5.40 | ||
| 176 | - @executor-js/api@1.4.60 | ||
| 177 | - @executor-js/runtime-quickjs@1.5.40 | ||
| 178 | |||
| 179 | ## 1.5.39 | ||
| 180 | |||
| 181 | ### Patch Changes | ||
| 182 | |||
| 183 | - Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: | ||
| 184 | - @executor-js/sdk@1.5.39 | ||
| 185 | - @executor-js/local@1.4.4 | ||
| 186 | - @executor-js/api@1.4.59 | ||
| 187 | - @executor-js/runtime-quickjs@1.5.39 | ||
| 188 | |||
| 189 | ## 1.5.38 | ||
| 190 | |||
| 191 | ### Patch Changes | ||
| 192 | |||
| 193 | - [#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. | ||
| 194 | |||
| 195 | - [#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** | ||
| 196 | |||
| 197 | - [#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. | ||
| 198 | |||
| 199 | - [#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** | ||
| 200 | |||
| 201 | - [#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.** | ||
| 202 | |||
| 203 | - [#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. | ||
| 204 | |||
| 205 | - [#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. | ||
| 206 | |||
| 207 | - Updated dependencies [[`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]: | ||
| 208 | - @executor-js/sdk@1.5.38 | ||
| 209 | - @executor-js/local@1.4.4 | ||
| 210 | - @executor-js/api@1.4.58 | ||
| 211 | - @executor-js/runtime-quickjs@1.5.38 | ||
| 212 | |||
| 213 | ## 1.5.37 | ||
| 214 | |||
| 215 | ### Patch Changes | ||
| 216 | |||
| 217 | - [#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. | ||
| 218 | |||
| 219 | - [#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. | ||
| 220 | |||
| 221 | - [#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** | ||
| 222 | |||
| 223 | - [#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. | ||
| 224 | |||
| 225 | - [#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. | ||
| 226 | |||
| 227 | - [#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** | ||
| 228 | |||
| 229 | - [#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** | ||
| 230 | |||
| 231 | - [#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** | ||
| 232 | |||
| 233 | 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. | ||
| 234 | |||
| 235 | - [#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** | ||
| 236 | |||
| 237 | 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. | ||
| 238 | |||
| 239 | - Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: | ||
| 240 | - @executor-js/sdk@1.5.37 | ||
| 241 | - @executor-js/api@1.4.57 | ||
| 242 | - @executor-js/local@1.4.4 | ||
| 243 | - @executor-js/runtime-quickjs@1.5.37 | ||
| 244 | |||
| 245 | ## 1.5.36 | ||
| 246 | |||
| 247 | ### Patch Changes | ||
| 248 | |||
| 249 | - [#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. | ||
| 250 | |||
| 251 | - [#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** | ||
| 252 | |||
| 253 | 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. | ||
| 254 | |||
| 255 | - [#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. | ||
| 256 | |||
| 257 | - [#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. | ||
| 258 | |||
| 259 | - [#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 | ||
| 260 | longer supported. The packed binary still ships the workerd and worker-bundler | ||
| 261 | sidecars. | ||
| 262 | - Updated dependencies []: | ||
| 263 | - @executor-js/sdk@1.5.36 | ||
| 264 | - @executor-js/runtime-quickjs@1.5.36 | ||
| 265 | - @executor-js/local@1.4.4 | ||
| 266 | - @executor-js/api@1.4.56 | ||
| 267 | |||
| 268 | ## 1.5.35 | ||
| 269 | |||
| 270 | ### Patch Changes | ||
| 271 | |||
| 272 | - Updated dependencies [[`1b9b1f1`](https://github.com/UsefulSoftwareCo/executor/commit/1b9b1f10313834a625a411169ebf83f6181589df), [`99c808f`](https://github.com/UsefulSoftwareCo/executor/commit/99c808f09d3cf2263945efa4f6592cc4e78c9e08)]: | ||
| 273 | - @executor-js/sdk@1.5.35 | ||
| 274 | - @executor-js/runtime-quickjs@1.5.35 | ||
| 275 | - @executor-js/local@1.4.4 | ||
| 276 | - @executor-js/api@1.4.55 | ||
| 277 | |||
| 278 | ## 1.5.34 | ||
| 279 | |||
| 280 | ### Patch Changes | ||
| 281 | |||
| 282 | - 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)]: | ||
| 283 | - @executor-js/sdk@1.5.34 | ||
| 284 | - @executor-js/local@1.4.4 | ||
| 285 | - @executor-js/api@1.4.54 | ||
| 286 | - @executor-js/runtime-quickjs@1.5.34 | ||
| 287 | |||
| 288 | ## 1.5.33 | ||
| 289 | |||
| 290 | ### Patch Changes | ||
| 291 | |||
| 292 | - [#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". | ||
| 293 | |||
| 294 | - Updated dependencies []: | ||
| 295 | - @executor-js/local@1.4.4 | ||
| 296 | - @executor-js/sdk@1.5.33 | ||
| 297 | - @executor-js/runtime-quickjs@1.5.33 | ||
| 298 | - @executor-js/api@1.4.53 | ||
| 299 | |||
| 300 | ## 1.5.32 | ||
| 301 | |||
| 302 | ### Patch Changes | ||
| 303 | |||
| 304 | - [#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. | ||
| 305 | |||
| 306 | - [#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". | ||
| 307 | |||
| 308 | - Updated dependencies []: | ||
| 309 | - @executor-js/sdk@1.5.32 | ||
| 310 | - @executor-js/runtime-quickjs@1.5.32 | ||
| 311 | - @executor-js/local@1.4.4 | ||
| 312 | - @executor-js/api@1.4.52 | ||
| 313 | |||
| 314 | ## 1.5.31 | ||
| 315 | |||
| 316 | ### Patch Changes | ||
| 317 | |||
| 318 | - [#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. | ||
| 319 | |||
| 320 | - Updated dependencies []: | ||
| 321 | - @executor-js/local@1.4.4 | ||
| 322 | - @executor-js/sdk@1.5.31 | ||
| 323 | - @executor-js/runtime-quickjs@1.5.31 | ||
| 324 | - @executor-js/api@1.4.51 | ||
| 325 | |||
| 326 | ## 1.5.30 | ||
| 327 | |||
| 328 | ### Patch Changes | ||
| 329 | |||
| 330 | - [#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. | ||
| 331 | |||
| 332 | - [#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. | ||
| 333 | |||
| 334 | - [#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. | ||
| 335 | |||
| 336 | - [#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. | ||
| 337 | |||
| 338 | - [#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. | ||
| 339 | |||
| 340 | - [#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()`. | ||
| 341 | |||
| 342 | - [#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. | ||
| 343 | |||
| 344 | - Updated dependencies []: | ||
| 345 | - @executor-js/sdk@1.5.30 | ||
| 346 | - @executor-js/runtime-quickjs@1.5.30 | ||
| 347 | - @executor-js/local@1.4.4 | ||
| 348 | - @executor-js/api@1.4.50 | ||
| 349 | |||
| 3 | ## 1.5.29 | 350 | ## 1.5.29 |
| 4 | 351 | ||
| 5 | ### Patch Changes | 352 | ### Patch Changes |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "executor", | 2 | "name": "executor", |
| 3 | "version": "1.5.29", | 3 | "version": "1.6.2", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "bin": { | 5 | "bin": { |
| 6 | "executor": "./bin/executor.ts" | 6 | "executor": "./bin/executor.ts" |
| @@ -36,8 +36,11 @@ · 29 unmodified lines | |||
| 36 | "quickjs-emscripten": "catalog:" | 36 | "quickjs-emscripten": "catalog:" |
| 37 | }, | 37 | }, |
| 38 | "devDependencies": { | 38 | "devDependencies": { |
| 39 | "@cloudflare/worker-bundler": "0.2.1", | ||
| 39 | "@effect/vitest": "catalog:", | 40 | "@effect/vitest": "catalog:", |
| 41 | "@types/node": "catalog:", | ||
| 40 | "bun-types": "catalog:", | 42 | "bun-types": "catalog:", |
| 43 | "esbuild": "^0.27.3", | ||
| 41 | "typescript": "catalog:", | 44 | "typescript": "catalog:", |
| 42 | "vitest": "catalog:" | 45 | "vitest": "catalog:" |
| 43 | } | 46 | } |
| @@ -2,15 +2,17 @@ import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; · 1 unmodified line | |||
| 2 | import { existsSync } from "node:fs"; | 2 | import { existsSync } from "node:fs"; |
| 3 | import { createRequire } from "node:module"; | 3 | import { createRequire } from "node:module"; |
| 4 | import { resolve, join, dirname } from "node:path"; | 4 | import { resolve, join, dirname } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 6 | import { parseArgs } from "node:util"; | 6 | import { parseArgs } from "node:util"; |
| 7 | import { $ } from "bun"; | 7 | import { $ } from "bun"; |
| 8 | import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | ||
| 8 | 9 | ||
| 9 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); | 10 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); |
| 10 | const cliRoot = resolve(repoRoot, "apps/cli"); | 11 | const cliRoot = resolve(repoRoot, "apps/cli"); |
| 11 | const webRoot = resolve(repoRoot, "apps/local"); | 12 | const webRoot = resolve(repoRoot, "apps/local"); |
| 12 | const distDir = resolve(cliRoot, "dist"); | 13 | const distDir = resolve(cliRoot, "dist"); |
| 13 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; | 14 | const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; |
| 15 | const WORKERD_VERSION = "1.20260708.1"; | ||
| 14 | 16 | ||
| 15 | const resolveQuickJsWasmPath = (): string => { | 17 | const resolveQuickJsWasmPath = (): string => { |
| 16 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); | 18 | const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); |
| @@ -23,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { · 6 unmodified lines | |||
| 23 | return wasmPath; | 25 | return wasmPath; |
| 24 | }; | 26 | }; |
| 25 | 27 | ||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // MCP-Apps shell (mcp-app.html) | ||
| 30 | // | ||
| 31 | // The MCP host serves the shell as the `ui://executor/shell.html` resource, and | ||
| 32 | // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime | ||
| 33 | // `fs.readFile` is invisible to `bun build --compile`, so without this the | ||
| 34 | // packaged binary would fail every artifact resource read. Build it if missing | ||
| 35 | // and copy it next to the executable, where the loader finds it via | ||
| 36 | // `process.execPath` — the same colocation trick used for `libsql.node` / | ||
| 37 | // `emscripten-module.wasm`. | ||
| 38 | // --------------------------------------------------------------------------- | ||
| 39 | |||
| 40 | const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); | ||
| 41 | const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); | ||
| 42 | |||
| 43 | const ensureMcpAppsShell = async (): Promise<string> => { | ||
| 44 | if (!existsSync(mcpAppsShellHtml)) { | ||
| 45 | console.log("Building MCP-Apps shell (mcp-app.html)..."); | ||
| 46 | await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); | ||
| 47 | } | ||
| 48 | if (!existsSync(mcpAppsShellHtml)) { | ||
| 49 | throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); | ||
| 50 | } | ||
| 51 | return mcpAppsShellHtml; | ||
| 52 | }; | ||
| 53 | |||
| 26 | const resolveOnePasswordCoreWasmPath = (): string => { | 54 | const resolveOnePasswordCoreWasmPath = (): string => { |
| 27 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); | 55 | const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); |
| 28 | const sdkPkg = req.resolve("@1password/sdk/package.json"); | 56 | const sdkPkg = req.resolve("@1password/sdk/package.json"); |
| @@ -33,6 +61,82 @@ const resolveOnePasswordCoreWasmPath = (): string => { · 4 unmodified lines | |||
| 33 | return wasmPath; | 61 | return wasmPath; |
| 34 | }; | 62 | }; |
| 35 | 63 | ||
| 64 | const resolveWorkerBundlerDistPath = (): string => { | ||
| 65 | const req = createRequire(join(repoRoot, "apps/cli/package.json")); | ||
| 66 | const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); | ||
| 67 | const distPath = join(dirname(pkgJson), "dist"); | ||
| 68 | if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { | ||
| 69 | throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`); | ||
| 70 | } | ||
| 71 | return distPath; | ||
| 72 | }; | ||
| 73 | |||
| 74 | const createPackedWorkerBundlerSource = async (distPath: string): Promise<string> => { | ||
| 75 | const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js"); | ||
| 76 | if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`); | ||
| 77 | const { build } = await import(pathToFileURL(esbuildPath).href); | ||
| 78 | const result = await build({ | ||
| 79 | entryPoints: [join(distPath, "index.js")], | ||
| 80 | bundle: true, | ||
| 81 | format: "esm", | ||
| 82 | platform: "browser", | ||
| 83 | external: ["./esbuild.wasm"], | ||
| 84 | logLevel: "silent", | ||
| 85 | write: false, | ||
| 86 | }); | ||
| 87 | const source = result.outputFiles[0]?.text; | ||
| 88 | if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); | ||
| 89 | return source; | ||
| 90 | }; | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Verify the staged worker-bundler beside a freshly compiled binary. | ||
| 94 | * | ||
| 95 | * The compiled binary cannot resolve `@cloudflare/worker-bundler` by name — | ||
| 96 | * bunfs has no node_modules, so a bare specifier throws ResolveMessage at | ||
| 97 | * runtime. The colocated copy IS the delivery mechanism, and until now nothing | ||
| 98 | * checked it after the copy: a partial staging shipped a binary that only fails | ||
| 99 | * on the user's machine, at startup. Assert it here, where a failure costs a | ||
| 100 | * red build instead of a crash loop. | ||
| 101 | * | ||
| 102 | * Checks match the depth of the asset assertions elsewhere in the repo: | ||
| 103 | * presence of every file the runtime contract names, a size floor on the packed | ||
| 104 | * entry, and the `\0asm` magic on the wasm so a truncated or LFS-pointer copy | ||
| 105 | * cannot pass as real. | ||
| 106 | */ | ||
| 107 | const assertPackedWorkerBundler = async (binDir: string, targetName: string): Promise<void> => { | ||
| 108 | const stagedDir = join(binDir, WORKER_BUNDLER_DIRNAME); | ||
| 109 | |||
| 110 | const missing = missingWorkerBundlerFiles(stagedDir, existsSync); | ||
| 111 | if (missing.length > 0) { | ||
| 112 | throw new Error( | ||
| 113 | `Packed @cloudflare/worker-bundler is incomplete for ${targetName}: missing ${missing.join(", ")} under ${stagedDir}. ` + | ||
| 114 | `The compiled binary resolves this package from disk, not from bunfs, so shipping it partial means a runtime failure on the user's machine.`, | ||
| 115 | ); | ||
| 116 | } | ||
| 117 | |||
| 118 | const bundledEntry = join(stagedDir, "dist", "index.bundled.js"); | ||
| 119 | const bundledSize = (await Bun.file(bundledEntry).arrayBuffer()).byteLength; | ||
| 120 | if (bundledSize < 100_000) { | ||
| 121 | throw new Error( | ||
| 122 | `Packed worker-bundler entry for ${targetName} is implausibly small (${bundledSize} bytes at ${bundledEntry}). Expected the esbuild-packed bundle.`, | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | const wasmPath = join(stagedDir, "dist", "esbuild.wasm"); | ||
| 127 | const wasmHead = new Uint8Array((await Bun.file(wasmPath).arrayBuffer()).slice(0, 4)); | ||
| 128 | if ( | ||
| 129 | wasmHead[0] !== 0x00 || | ||
| 130 | wasmHead[1] !== 0x61 || | ||
| 131 | wasmHead[2] !== 0x73 || | ||
| 132 | wasmHead[3] !== 0x6d | ||
| 133 | ) { | ||
| 134 | throw new Error( | ||
| 135 | `Staged esbuild.wasm for ${targetName} is not a WebAssembly module (bad magic at ${wasmPath}).`, | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 36 | // --------------------------------------------------------------------------- | 140 | // --------------------------------------------------------------------------- |
| 37 | // Metadata | 141 | // Metadata |
| 38 | // --------------------------------------------------------------------------- | 142 | // --------------------------------------------------------------------------- |
| @@ -119,6 +223,7 @@ const bunTarget = (t: Target): Bun.Build.CompileTarget => { · 80 unmodified lines | |||
| 119 | }; | 223 | }; |
| 120 | 224 | ||
| 121 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); | 225 | const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); |
| 226 | const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd"); | ||
| 122 | 227 | ||
| 123 | const isCurrentPlatform = (t: Target) => | 228 | const isCurrentPlatform = (t: Target) => |
| 124 | t.os === process.platform && t.arch === process.arch && !t.abi; | 229 | t.os === process.platform && t.arch === process.arch && !t.abi; |
| @@ -211,6 +316,41 @@ const resolveLibsqlNative = (t: Target): string | null => { · 86 unmodified lines | |||
| 211 | } | 316 | } |
| 212 | }; | 317 | }; |
| 213 | 318 | ||
| 319 | const resolveWorkerdBinary = (t: Target): string | null => { | ||
| 320 | const platformMap: Record<string, string> = { | ||
| 321 | "darwin-arm64": "@cloudflare/workerd-darwin-arm64", | ||
| 322 | "darwin-x64": "@cloudflare/workerd-darwin-64", | ||
| 323 | "linux-arm64": "@cloudflare/workerd-linux-arm64", | ||
| 324 | "linux-x64": "@cloudflare/workerd-linux-64", | ||
| 325 | "win32-x64": "@cloudflare/workerd-windows-64", | ||
| 326 | }; | ||
| 327 | const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); | ||
| 328 | const pkg = platformMap[key]; | ||
| 329 | if (!pkg) return null; | ||
| 330 | const binary = workerdBinaryName(t); | ||
| 331 | try { | ||
| 332 | const req = createRequire( | ||
| 333 | join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"), | ||
| 334 | ); | ||
| 335 | const pkgJson = req.resolve(`${pkg}/package.json`); | ||
| 336 | for (const candidate of [ | ||
| 337 | join(dirname(pkgJson), "bin", binary), | ||
| 338 | join(dirname(pkgJson), binary), | ||
| 339 | ]) { | ||
| 340 | if (existsSync(candidate)) return candidate; | ||
| 341 | } | ||
| 342 | } catch { | ||
| 343 | const packageRoot = join( | ||
| 344 | repoRoot, | ||
| 345 | `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`, | ||
| 346 | ); | ||
| 347 | for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) { | ||
| 348 | if (existsSync(candidate)) return candidate; | ||
| 349 | } | ||
| 350 | } | ||
| 351 | return null; | ||
| 352 | }; | ||
| 353 | |||
| 214 | // --------------------------------------------------------------------------- | 354 | // --------------------------------------------------------------------------- |
| 215 | // Build mode | 355 | // Build mode |
| 216 | // --------------------------------------------------------------------------- | 356 | // --------------------------------------------------------------------------- |
| @@ -332,7 +472,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 115 unmodified lines | |||
| 332 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); | 472 | await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); |
| 333 | 473 | ||
| 334 | const quickJsWasmPath = resolveQuickJsWasmPath(); | 474 | const quickJsWasmPath = resolveQuickJsWasmPath(); |
| 475 | const mcpAppsShellPath = await ensureMcpAppsShell(); | ||
| 335 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); | 476 | const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); |
| 477 | const workerBundlerDistPath = resolveWorkerBundlerDistPath(); | ||
| 478 | const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); | ||
| 336 | 479 | ||
| 337 | try { | 480 | try { |
| 338 | for (const target of targets) { | 481 | for (const target of targets) { |
| @@ -355,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 16 unmodified lines | |||
| 355 | // Copy QuickJS WASM next to binary — loaded at runtime by the server | 498 | // Copy QuickJS WASM next to binary — loaded at runtime by the server |
| 356 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); | 499 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); |
| 357 | 500 | ||
| 501 | // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, | ||
| 502 | // read from execDir at runtime by @executor-js/mcp-apps-shell. | ||
| 503 | await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); | ||
| 504 | |||
| 358 | // Copy 1Password's sdk-core WASM next to the binary. The patched | 505 | // Copy 1Password's sdk-core WASM next to the binary. The patched |
| 359 | // sdk-core loader falls back to this sibling file when bun --compile | 506 | // sdk-core loader falls back to this sibling file when bun --compile |
| 360 | // bakes a build-machine node_modules path into __dirname. | 507 | // bakes a build-machine node_modules path into __dirname. |
| @@ -375,6 +522,21 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 14 unmodified lines | |||
| 375 | await cp(libsqlNative, join(binDir, "libsql.node")); | 522 | await cp(libsqlNative, join(binDir, "libsql.node")); |
| 376 | } | 523 | } |
| 377 | 524 | ||
| 525 | const workerdBinary = resolveWorkerdBinary(target); | ||
| 526 | if (workerdBinary && existsSync(workerdBinary)) { | ||
| 527 | await cp(workerdBinary, join(binDir, workerdBinaryName(target))); | ||
| 528 | if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755); | ||
| 529 | } | ||
| 530 | |||
| 531 | await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), { | ||
| 532 | recursive: true, | ||
| 533 | }); | ||
| 534 | await writeFile( | ||
| 535 | join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"), | ||
| 536 | packedWorkerBundlerSource, | ||
| 537 | ); | ||
| 538 | await assertPackedWorkerBundler(binDir, name); | ||
| 539 | |||
| 378 | // Smoke test on current platform | 540 | // Smoke test on current platform |
| 379 | if (isCurrentPlatform(target)) { | 541 | if (isCurrentPlatform(target)) { |
| 380 | const bin = join(binDir, binaryName(target)); | 542 | const bin = join(binDir, binaryName(target)); |
| @@ -1,6 +1,44 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { browserOpenCommand } from "./device-login"; | 3 | import { |
| 4 | browserOpenCommand, | ||
| 5 | discoverCliLogin, | ||
| 6 | refreshDeviceTokens, | ||
| 7 | requestDeviceCode, | ||
| 8 | type CliLoginDiscovery, | ||
| 9 | } from "./device-login"; | ||
| 10 | |||
| 11 | const originalFetch = globalThis.fetch; | ||
| 12 | |||
| 13 | interface FetchCall { | ||
| 14 | readonly url: string; | ||
| 15 | readonly headers: Record<string, string>; | ||
| 16 | } | ||
| 17 | |||
| 18 | const responseJson = (body: Record<string, unknown>, status = 200): Response => | ||
| 19 | new Response(JSON.stringify(body), { | ||
| 20 | status, | ||
| 21 | headers: { "content-type": "application/json" }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => { | ||
| 25 | globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { | ||
| 26 | const url = | ||
| 27 | typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; | ||
| 28 | return Promise.resolve(handler(url, init)); | ||
| 29 | }) as typeof fetch; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => { | ||
| 33 | calls.push({ | ||
| 34 | url, | ||
| 35 | headers: Object.fromEntries(new Headers(init?.headers).entries()), | ||
| 36 | }); | ||
| 37 | }; | ||
| 38 | |||
| 39 | afterEach(() => { | ||
| 40 | globalThis.fetch = originalFetch; | ||
| 41 | }); | ||
| 4 | 42 | ||
| 5 | describe("browserOpenCommand", () => { | 43 | describe("browserOpenCommand", () => { |
| 6 | it("opens Windows browser URLs without cmd.exe", () => { | 44 | it("opens Windows browser URLs without cmd.exe", () => { |
| @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines | |||
| 32 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); | 70 | expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); |
| 33 | }); | 71 | }); |
| 34 | }); | 72 | }); |
| 73 | |||
| 74 | describe("device login headers", () => { | ||
| 75 | it("sends configured headers when discovering CLI login", async () => { | ||
| 76 | const calls: Array<FetchCall> = []; | ||
| 77 | installFetch((url, init) => { | ||
| 78 | recordCall(calls, url, init); | ||
| 79 | return responseJson({ | ||
| 80 | provider: "better-auth", | ||
| 81 | deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", | ||
| 82 | tokenEndpoint: "https://executor.example/api/auth/device/token", | ||
| 83 | clientId: "executor-cli", | ||
| 84 | requestFormat: "json", | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | const discovery = await discoverCliLogin("https://executor.example", { | ||
| 89 | headers: { "CF-Access-Client-Id": "client-id" }, | ||
| 90 | }); | ||
| 91 | |||
| 92 | expect(discovery.clientId).toBe("executor-cli"); | ||
| 93 | expect(calls).toHaveLength(1); | ||
| 94 | expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login"); | ||
| 95 | expect(calls[0]?.headers).toMatchObject({ | ||
| 96 | accept: "application/json", | ||
| 97 | "cf-access-client-id": "client-id", | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("sends configured headers only to same-origin device endpoints", async () => { | ||
| 102 | const calls: Array<FetchCall> = []; | ||
| 103 | installFetch((url, init) => { | ||
| 104 | recordCall(calls, url, init); | ||
| 105 | if (url.endsWith("/api/auth/device/code")) { | ||
| 106 | return responseJson({ | ||
| 107 | device_code: "device-code", | ||
| 108 | user_code: "USER-CODE", | ||
| 109 | verification_uri: "https://executor.example/device", | ||
| 110 | expires_in: 300, | ||
| 111 | interval: 5, | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | return responseJson({ | ||
| 115 | access_token: "access-token", | ||
| 116 | refresh_token: "refresh-token-2", | ||
| 117 | expires_in: 600, | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | const discovery: CliLoginDiscovery = { | ||
| 121 | provider: "better-auth", | ||
| 122 | deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", | ||
| 123 | tokenEndpoint: "https://accounts.example/oauth/token", | ||
| 124 | clientId: "executor-cli", | ||
| 125 | requestFormat: "form", | ||
| 126 | }; | ||
| 127 | const headers = { "CF-Access-Client-Id": "client-id" }; | ||
| 128 | |||
| 129 | await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers }); | ||
| 130 | await refreshDeviceTokens({ | ||
| 131 | tokenEndpoint: discovery.tokenEndpoint, | ||
| 132 | clientId: discovery.clientId, | ||
| 133 | refreshToken: "refresh-token", | ||
| 134 | serverOrigin: "https://executor.example", | ||
| 135 | headers, | ||
| 136 | }); | ||
| 137 | |||
| 138 | expect(calls).toHaveLength(2); | ||
| 139 | expect(calls[0]?.headers).toMatchObject({ | ||
| 140 | accept: "application/json", | ||
| 141 | "content-type": "application/x-www-form-urlencoded", | ||
| 142 | "cf-access-client-id": "client-id", | ||
| 143 | }); | ||
| 144 | expect(calls[1]?.headers).toMatchObject({ | ||
| 145 | accept: "application/json", | ||
| 146 | "content-type": "application/x-www-form-urlencoded", | ||
| 147 | }); | ||
| 148 | expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined(); | ||
| 149 | }); | ||
| 150 | }); | ||
| @@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines | |||
| 50 | readonly organizationId?: string; | 50 | readonly organizationId?: string; |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | export interface DeviceLoginHttpOptions { | ||
| 54 | readonly headers?: Readonly<Record<string, string>>; | ||
| 55 | readonly serverOrigin?: string; | ||
| 56 | } | ||
| 57 | |||
| 58 | export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions { | ||
| 59 | readonly now?: () => number; | ||
| 60 | } | ||
| 61 | |||
| 53 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | 62 | const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; |
| 54 | const DEFAULT_INTERVAL_SECONDS = 5; | 63 | const DEFAULT_INTERVAL_SECONDS = 5; |
| 55 | 64 | ||
| @@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines | |||
| 126 | string | 135 | string |
| 127 | >; | 136 | >; |
| 128 | 137 | ||
| 138 | const isSameOrigin = (url: string, origin: string): boolean => { | ||
| 139 | try { | ||
| 140 | return new URL(url).origin === new URL(origin).origin; | ||
| 141 | } catch { | ||
| 142 | return false; | ||
| 143 | } | ||
| 144 | }; | ||
| 145 | |||
| 146 | const headersForUrl = ( | ||
| 147 | url: string, | ||
| 148 | baseHeaders: Record<string, string>, | ||
| 149 | options: DeviceLoginHttpOptions = {}, | ||
| 150 | ): Record<string, string> => { | ||
| 151 | const configured = | ||
| 152 | options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin)) | ||
| 153 | ? options.headers | ||
| 154 | : {}; | ||
| 155 | return { ...configured, ...baseHeaders }; | ||
| 156 | }; | ||
| 157 | |||
| 129 | const post = async ( | 158 | const post = async ( |
| 130 | url: string, | 159 | url: string, |
| 131 | fields: Record<string, string | undefined>, | 160 | fields: Record<string, string | undefined>, |
| 132 | format: "form" | "json", | 161 | format: "form" | "json", |
| 162 | options: DeviceLoginHttpOptions = {}, | ||
| 133 | ) => | 163 | ) => |
| 134 | fetch(url, { | 164 | fetch(url, { |
| 135 | method: "POST", | 165 | method: "POST", |
| 136 | headers: { | 166 | headers: headersForUrl( |
| 137 | "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", | 167 | url, |
| 138 | accept: "application/json", | 168 | { |
| 139 | }, | 169 | "content-type": |
| 170 | format === "json" ? "application/json" : "application/x-www-form-urlencoded", | ||
| 171 | accept: "application/json", | ||
| 172 | }, | ||
| 173 | options, | ||
| 174 | ), | ||
| 140 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), | 175 | body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), |
| 141 | }); | 176 | }); |
| 142 | 177 | ||
| @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines | |||
| 150 | } | 185 | } |
| 151 | }; | 186 | }; |
| 152 | 187 | ||
| 153 | export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => { | 188 | export const discoverCliLogin = async ( |
| 189 | origin: string, | ||
| 190 | options: DeviceLoginHttpOptions = {}, | ||
| 191 | ): Promise<CliLoginDiscovery> => { | ||
| 154 | let response: Response; | 192 | let response: Response; |
| 193 | const url = cliLoginUrl(origin); | ||
| 155 | try { | 194 | try { |
| 156 | response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); | 195 | response = await fetch(url, { |
| 196 | headers: headersForUrl(url, { accept: "application/json" }, options), | ||
| 197 | }); | ||
| 157 | } catch (cause) { | 198 | } catch (cause) { |
| 158 | throw new DeviceLoginError( | 199 | throw new DeviceLoginError( |
| 159 | `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, | 200 | `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, |
| @@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines | |||
| 182 | }; | 223 | }; |
| 183 | }; | 224 | }; |
| 184 | 225 | ||
| 185 | export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => { | 226 | export const requestDeviceCode = async ( |
| 227 | discovery: CliLoginDiscovery, | ||
| 228 | options: DeviceLoginHttpOptions = {}, | ||
| 229 | ): Promise<DeviceCodeGrant> => { | ||
| 186 | const response = await post( | 230 | const response = await post( |
| 187 | discovery.deviceAuthorizationEndpoint, | 231 | discovery.deviceAuthorizationEndpoint, |
| 188 | { client_id: discovery.clientId, scope: discovery.scope }, | 232 | { client_id: discovery.clientId, scope: discovery.scope }, |
| 189 | discovery.requestFormat, | 233 | discovery.requestFormat, |
| 234 | options, | ||
| 190 | ); | 235 | ); |
| 191 | const body = await readJson(response); | 236 | const body = await readJson(response); |
| 192 | if (!response.ok) { | 237 | if (!response.ok) { |
| @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines | |||
| 220 | export const pollForDeviceTokens = async ( | 265 | export const pollForDeviceTokens = async ( |
| 221 | discovery: CliLoginDiscovery, | 266 | discovery: CliLoginDiscovery, |
| 222 | grant: DeviceCodeGrant, | 267 | grant: DeviceCodeGrant, |
| 223 | options: { readonly now?: () => number } = {}, | 268 | options: PollForDeviceTokensOptions = {}, |
| 224 | ): Promise<DeviceTokens> => { | 269 | ): Promise<DeviceTokens> => { |
| 225 | const now = options.now ?? (() => Date.now()); | 270 | const now = options.now ?? (() => Date.now()); |
| 226 | const deadline = now() + grant.expiresInSeconds * 1000; | 271 | const deadline = now() + grant.expiresInSeconds * 1000; |
| @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines | |||
| 240 | client_id: discovery.clientId, | 285 | client_id: discovery.clientId, |
| 241 | }, | 286 | }, |
| 242 | discovery.requestFormat, | 287 | discovery.requestFormat, |
| 288 | options, | ||
| 243 | ); | 289 | ); |
| 244 | const body = await readJson(response); | 290 | const body = await readJson(response); |
| 245 | 291 | ||
| @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines | |||
| 286 | readonly tokenEndpoint: string; | 332 | readonly tokenEndpoint: string; |
| 287 | readonly clientId: string; | 333 | readonly clientId: string; |
| 288 | readonly refreshToken: string; | 334 | readonly refreshToken: string; |
| 335 | readonly headers?: Readonly<Record<string, string>>; | ||
| 336 | readonly serverOrigin?: string; | ||
| 289 | }): Promise<DeviceTokens> => { | 337 | }): Promise<DeviceTokens> => { |
| 290 | const response = await post( | 338 | const response = await post( |
| 291 | input.tokenEndpoint, | 339 | input.tokenEndpoint, |
| @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines | |||
| 295 | client_id: input.clientId, | 343 | client_id: input.clientId, |
| 296 | }, | 344 | }, |
| 297 | "form", | 345 | "form", |
| 346 | input, | ||
| 298 | ); | 347 | ); |
| 299 | const body = await readJson(response); | 348 | const body = await readJson(response); |
| 300 | if (!response.ok) { | 349 | if (!response.ok) { |
| @@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines | |||
| 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; | 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; | 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 66 | import { HttpApiClient } from "effect/unstable/httpapi"; | 66 | import { HttpApiClient } from "effect/unstable/httpapi"; |
| 67 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 67 | import { |
| 68 | FetchHttpClient, | ||
| 69 | HttpClient, | ||
| 70 | HttpClientError, | ||
| 71 | HttpClientRequest, | ||
| 72 | } from "effect/unstable/http"; | ||
| 68 | import { FileSystem, Path as PlatformPath } from "effect"; | 73 | import { FileSystem, Path as PlatformPath } from "effect"; |
| 69 | import type { PlatformError } from "effect/PlatformError"; | 74 | import type { PlatformError } from "effect/PlatformError"; |
| 70 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| @@ -79,10 +84,13 @@ import { · 8 unmodified lines | |||
| 79 | getExecutorServerAuthorizationHeader, | 84 | getExecutorServerAuthorizationHeader, |
| 80 | normalizeExecutorServerConnection, | 85 | normalizeExecutorServerConnection, |
| 81 | normalizeExecutorServerOrigin, | 86 | normalizeExecutorServerOrigin, |
| 87 | resolveExecutorServerConfiguredHeaders, | ||
| 88 | resolveExecutorServerRequestHeaders, | ||
| 82 | type ExecutorLocalServerKind, | 89 | type ExecutorLocalServerKind, |
| 83 | type ExecutorLocalServerManifest, | 90 | type ExecutorLocalServerManifest, |
| 84 | type ExecutorServerConnection, | 91 | type ExecutorServerConnection, |
| 85 | type ExecutorServerConnectionInput, | 92 | type ExecutorServerConnectionInput, |
| 93 | type ExecutorServerHeaders, | ||
| 86 | } from "@executor-js/sdk/shared"; | 94 | } from "@executor-js/sdk/shared"; |
| 87 | import { | 95 | import { |
| 88 | decodeAccessTokenClaims, | 96 | decodeAccessTokenClaims, |
| @@ -131,7 +139,9 @@ import { · 42 unmodified lines | |||
| 131 | import { | 139 | import { |
| 132 | canAutoStartCliServerConnection, | 140 | canAutoStartCliServerConnection, |
| 133 | chooseCliServerConnectionWithActiveLocal, | 141 | chooseCliServerConnectionWithActiveLocal, |
| 142 | describeUnauthorizedCliServer, | ||
| 134 | parseCliExecutorServerConnection, | 143 | parseCliExecutorServerConnection, |
| 144 | profileNameFromConnectionKey, | ||
| 135 | type CliServerConnectionSource, | 145 | type CliServerConnectionSource, |
| 136 | withCliServerAuthFallback, | 146 | withCliServerAuthFallback, |
| 137 | } from "./server-connection"; | 147 | } from "./server-connection"; |
| @@ -162,7 +172,7 @@ import { · 24 unmodified lines | |||
| 162 | buildDescribeToolCode, | 172 | buildDescribeToolCode, |
| 163 | filterToolPathChildren, | 173 | filterToolPathChildren, |
| 164 | buildInvokeToolCode, | 174 | buildInvokeToolCode, |
| 165 | buildListSourcesCode, | 175 | buildListIntegrationsCode, |
| 166 | buildSearchToolsCode, | 176 | buildSearchToolsCode, |
| 167 | extractExecutionId, | 177 | extractExecutionId, |
| 168 | extractPausedInteraction, | 178 | extractPausedInteraction, |
| @@ -205,9 +215,11 @@ const waitForShutdownSignal = () => · 36 unmodified lines | |||
| 205 | const shutdown = () => resume(Effect.void); | 215 | const shutdown = () => resume(Effect.void); |
| 206 | process.once("SIGINT", shutdown); | 216 | process.once("SIGINT", shutdown); |
| 207 | process.once("SIGTERM", shutdown); | 217 | process.once("SIGTERM", shutdown); |
| 218 | process.once("SIGHUP", shutdown); | ||
| 208 | return Effect.sync(() => { | 219 | return Effect.sync(() => { |
| 209 | process.off("SIGINT", shutdown); | 220 | process.off("SIGINT", shutdown); |
| 210 | process.off("SIGTERM", shutdown); | 221 | process.off("SIGTERM", shutdown); |
| 222 | process.off("SIGHUP", shutdown); | ||
| 211 | }); | 223 | }); |
| 212 | }); | 224 | }); |
| 213 | 225 | ||
| @@ -743,12 +755,9 @@ const resolveRequestedExecutorServerConnection = ( · 529 unmodified lines | |||
| 743 | // login. The refreshed tokens are written back to the originating profile. | 755 | // login. The refreshed tokens are written back to the originating profile. |
| 744 | const OAUTH_REFRESH_SKEW_SECONDS = 60; | 756 | const OAUTH_REFRESH_SKEW_SECONDS = 60; |
| 745 | 757 | ||
| 746 | const profileNameFromKey = (key: string): string | null => | ||
| 747 | key.startsWith("profile:") ? key.slice("profile:".length) : null; | ||
| 748 | |||
| 749 | const refreshOAuthConnection = ( | 758 | const refreshOAuthConnection = ( |
| 750 | connection: ExecutorServerConnection, | 759 | connection: ExecutorServerConnection, |
| 751 | ): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | 760 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 752 | Effect.gen(function* () { | 761 | Effect.gen(function* () { |
| 753 | const auth = connection.auth; | 762 | const auth = connection.auth; |
| 754 | if (!auth || auth.kind !== "oauth") return connection; | 763 | if (!auth || auth.kind !== "oauth") return connection; |
| @@ -759,8 +768,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines | |||
| 759 | const { refreshToken, tokenEndpoint, clientId } = auth; | 768 | const { refreshToken, tokenEndpoint, clientId } = auth; |
| 760 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; | 769 | if (!refreshToken || !tokenEndpoint || !clientId) return connection; |
| 761 | 770 | ||
| 771 | const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( | ||
| 772 | Effect.mapError(toError), | ||
| 773 | ); | ||
| 774 | |||
| 762 | const refreshed = yield* Effect.tryPromise({ | 775 | const refreshed = yield* Effect.tryPromise({ |
| 763 | try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), | 776 | try: () => |
| 777 | refreshDeviceTokens({ | ||
| 778 | tokenEndpoint, | ||
| 779 | clientId, | ||
| 780 | refreshToken, | ||
| 781 | serverOrigin: connection.origin, | ||
| 782 | headers, | ||
| 783 | }), | ||
| 764 | catch: toError, | 784 | catch: toError, |
| 765 | // On a failed refresh, keep the existing token and let the eventual 401 | 785 | // On a failed refresh, keep the existing token and let the eventual 401 |
| 766 | // surface, better than blocking the command on a transient hiccup. | 786 | // surface, better than blocking the command on a transient hiccup. |
| @@ -780,7 +800,7 @@ const refreshOAuthConnection = ( · 13 unmodified lines | |||
| 780 | }, | 800 | }, |
| 781 | }); | 801 | }); |
| 782 | 802 | ||
| 783 | const profileName = profileNameFromKey(connection.key); | 803 | const profileName = profileNameFromConnectionKey(connection.key); |
| 784 | if (profileName) { | 804 | if (profileName) { |
| 785 | yield* upsertCliServerConnectionProfile({ | 805 | yield* upsertCliServerConnectionProfile({ |
| 786 | name: profileName, | 806 | name: profileName, |
| @@ -961,7 +981,7 @@ const executeCode = (input: { · 174 unmodified lines | |||
| 961 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => | 981 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 962 | Effect.gen(function* () { | 982 | Effect.gen(function* () { |
| 963 | const connection = yield* resolveExecutorServerConnection(input.target); | 983 | const connection = yield* resolveExecutorServerConnection(input.target); |
| 964 | const client = yield* makeApiClient(connection); | 984 | const client = yield* makeApiClient(connection, input.target); |
| 965 | const response = yield* client.executions.execute({ | 985 | const response = yield* client.executions.execute({ |
| 966 | payload: { | 986 | payload: { |
| 967 | code: input.code, | 987 | code: input.code, |
| @@ -1050,19 +1070,36 @@ const printExecutionOutcome = (input: { · 82 unmodified lines | |||
| 1050 | // Typed API client | 1070 | // Typed API client |
| 1051 | // --------------------------------------------------------------------------- | 1071 | // --------------------------------------------------------------------------- |
| 1052 | 1072 | ||
| 1053 | const makeApiClient = (connection: ExecutorServerConnection) => { | 1073 | const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => |
| 1054 | const authorization = getExecutorServerAuthorizationHeader(connection); | 1074 | Effect.gen(function* () { |
| 1055 | return HttpApiClient.make(ExecutorApi, { | 1075 | const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( |
| 1056 | baseUrl: connection.apiBaseUrl, | 1076 | Effect.mapError(toError), |
| 1057 | ...(authorization | 1077 | ); |
| 1058 | ? { | 1078 | return yield* HttpApiClient.make(ExecutorApi, { |
| 1059 | transformClient: HttpClient.mapRequest((request) => | 1079 | baseUrl: connection.apiBaseUrl, |
| 1060 | HttpClientRequest.setHeader(request, "authorization", authorization), | 1080 | ...(Object.keys(headers).length > 0 |
| 1061 | ), | 1081 | ? { |
| 1062 | } | 1082 | transformClient: HttpClient.mapRequest((request) => |
| 1063 | : {}), | 1083 | HttpClientRequest.setHeaders(request, headers), |
| 1084 | ), | ||
| 1085 | } | ||
| 1086 | : {}), | ||
| 1087 | // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite | ||
| 1088 | // the transport-level error into the login hint. Without this the client | ||
| 1089 | // fails decoding the unexpected status and prints the opaque | ||
| 1090 | // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) | ||
| 1091 | // decode before this catch and pass through untouched. | ||
| 1092 | transformResponse: (effect) => | ||
| 1093 | Effect.catchIf( | ||
| 1094 | effect, | ||
| 1095 | (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, | ||
| 1096 | () => | ||
| 1097 | Effect.fail( | ||
| 1098 | new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })), | ||
| 1099 | ), | ||
| 1100 | ), | ||
| 1101 | }); | ||
| 1064 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 1102 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1065 | }; | ||
| 1066 | 1103 | ||
| 1067 | // --------------------------------------------------------------------------- | 1104 | // --------------------------------------------------------------------------- |
| 1068 | // Foreground session | 1105 | // Foreground session |
| @@ -1321,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { · 252 unmodified lines | |||
| 1321 | // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. | 1358 | // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. |
| 1322 | // --------------------------------------------------------------------------- | 1359 | // --------------------------------------------------------------------------- |
| 1323 | 1360 | ||
| 1324 | const mcpUrlForActiveLocalServer = ( | 1361 | const mcpUrlForActiveLocalServer = (input: { |
| 1325 | connection: ExecutorServerConnection, | 1362 | readonly connection: ExecutorServerConnection; |
| 1326 | elicitationMode: "browser" | "model", | 1363 | readonly elicitationMode: "browser" | "model"; |
| 1327 | ): URL => { | 1364 | readonly artifacts: boolean; |
| 1328 | const url = new URL("/mcp", connection.origin); | 1365 | readonly searchTools: boolean; |
| 1329 | if (elicitationMode === "browser") { | 1366 | }): URL => { |
| 1367 | const url = new URL("/mcp", input.connection.origin); | ||
| 1368 | if (input.elicitationMode === "browser") { | ||
| 1330 | url.searchParams.set("elicitation_mode", "browser"); | 1369 | url.searchParams.set("elicitation_mode", "browser"); |
| 1331 | } | 1370 | } |
| 1371 | // Artifacts are on by default; only the opt-out is spelled out, so the | ||
| 1372 | // default endpoint stays clean. | ||
| 1373 | if (!input.artifacts) { | ||
| 1374 | url.searchParams.set("artifacts", "false"); | ||
| 1375 | } | ||
| 1376 | // Per-integration search tools are off by default; only the opt-in is | ||
| 1377 | // spelled out. | ||
| 1378 | if (input.searchTools) { | ||
| 1379 | url.searchParams.set("search_tools", "true"); | ||
| 1380 | } | ||
| 1332 | return url; | 1381 | return url; |
| 1333 | }; | 1382 | }; |
| 1334 | 1383 | ||
| @@ -1343,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( · 8 unmodified lines | |||
| 1343 | const runMcpHttpBridge = async (input: { | 1392 | const runMcpHttpBridge = async (input: { |
| 1344 | readonly manifest: ExecutorLocalServerManifest; | 1393 | readonly manifest: ExecutorLocalServerManifest; |
| 1345 | readonly elicitationMode: "browser" | "model"; | 1394 | readonly elicitationMode: "browser" | "model"; |
| 1395 | readonly artifacts: boolean; | ||
| 1396 | readonly searchTools: boolean; | ||
| 1346 | }): Promise<void> => { | 1397 | }): Promise<void> => { |
| 1347 | const stdio = new StdioServerTransport(); | 1398 | const stdio = new StdioServerTransport(); |
| 1348 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); | 1399 | const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); |
| 1349 | const http = new StreamableHTTPClientTransport( | 1400 | const http = new StreamableHTTPClientTransport( |
| 1350 | mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), | 1401 | mcpUrlForActiveLocalServer({ |
| 1402 | connection: input.manifest.connection, | ||
| 1403 | elicitationMode: input.elicitationMode, | ||
| 1404 | artifacts: input.artifacts, | ||
| 1405 | searchTools: input.searchTools, | ||
| 1406 | }), | ||
| 1351 | authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, | 1407 | authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, |
| 1352 | ); | 1408 | ); |
| 1353 | 1409 | ||
| @@ -1422,7 +1478,11 @@ const runMcpHttpBridge = async (input: { · 68 unmodified lines | |||
| 1422 | } | 1478 | } |
| 1423 | }; | 1479 | }; |
| 1424 | 1480 | ||
| 1425 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => | 1481 | const runStdioMcpSession = (input: { |
| 1482 | readonly elicitationMode: "browser" | "model"; | ||
| 1483 | readonly artifacts: boolean; | ||
| 1484 | readonly searchTools: boolean; | ||
| 1485 | }) => | ||
| 1426 | Effect.gen(function* () { | 1486 | Effect.gen(function* () { |
| 1427 | // `executor mcp` never owns the local database. If a local server is already | 1487 | // `executor mcp` never owns the local database. If a local server is already |
| 1428 | // running, bridge this stdio client to it; otherwise ensure a durable | 1488 | // running, bridge this stdio client to it; otherwise ensure a durable |
| @@ -1434,7 +1494,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 5 unmodified lines | |||
| 1434 | const active = yield* readActiveLocalServerManifest(); | 1494 | const active = yield* readActiveLocalServerManifest(); |
| 1435 | if (active) { | 1495 | if (active) { |
| 1436 | yield* Effect.promise(() => | 1496 | yield* Effect.promise(() => |
| 1437 | runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), | 1497 | runMcpHttpBridge({ |
| 1498 | manifest: active, | ||
| 1499 | elicitationMode: input.elicitationMode, | ||
| 1500 | artifacts: input.artifacts, | ||
| 1501 | searchTools: input.searchTools, | ||
| 1502 | }), | ||
| 1438 | ); | 1503 | ); |
| 1439 | return; | 1504 | return; |
| 1440 | } | 1505 | } |
| @@ -1456,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 15 unmodified lines | |||
| 1456 | ); | 1521 | ); |
| 1457 | } | 1522 | } |
| 1458 | yield* Effect.promise(() => | 1523 | yield* Effect.promise(() => |
| 1459 | runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }), | 1524 | runMcpHttpBridge({ |
| 1525 | manifest: elected, | ||
| 1526 | elicitationMode: input.elicitationMode, | ||
| 1527 | artifacts: input.artifacts, | ||
| 1528 | searchTools: input.searchTools, | ||
| 1529 | }), | ||
| 1460 | ); | 1530 | ); |
| 1461 | }); | 1531 | }); |
| 1462 | 1532 | ||
| @@ -1504,14 +1574,45 @@ const parseOptionalJsonObject = ( · 41 unmodified lines | |||
| 1504 | Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)), | 1574 | Effect.mapError((error) => new Error(`Invalid --content JSON: ${error.message}`)), |
| 1505 | ); | 1575 | ); |
| 1506 | 1576 | ||
| 1577 | const ownMessage = (value: unknown): string => { | ||
| 1578 | if (typeof value === "string") return value.trim(); | ||
| 1579 | if (typeof value === "object" && value !== null && "message" in value) { | ||
| 1580 | const message = (value as { message: unknown }).message; | ||
| 1581 | if (typeof message === "string") return message.trim(); | ||
| 1582 | } | ||
| 1583 | return ""; | ||
| 1584 | }; | ||
| 1585 | |||
| 1586 | /** | ||
| 1587 | * Render an unknown failure by walking its `cause` chain. Wrapper errors | ||
| 1588 | * (tagged errors carrying only `{ cause }`) have an empty own message; without | ||
| 1589 | * descending, a boot failure like a data-migration error printed as literally | ||
| 1590 | * "Unknown error" (issue #1403). Nested messages that merely repeat their | ||
| 1591 | * parent are dropped. | ||
| 1592 | */ | ||
| 1507 | const formatUnknownMessage = (cause: unknown): string => { | 1593 | const formatUnknownMessage = (cause: unknown): string => { |
| 1508 | if (cause instanceof Error) return cause.message; | 1594 | const messages: string[] = []; |
| 1509 | if (typeof cause === "string") return cause; | 1595 | const seen = new Set<unknown>(); |
| 1510 | if (typeof cause === "object" && cause !== null && "message" in cause) { | 1596 | let current: unknown = cause; |
| 1511 | const message = cause.message; | 1597 | while (current !== null && current !== undefined && !seen.has(current)) { |
| 1512 | if (typeof message === "string") return message; | 1598 | seen.add(current); |
| 1599 | const message = ownMessage(current); | ||
| 1600 | if (message.length > 0 && !messages.some((existing) => existing.includes(message))) { | ||
| 1601 | messages.push(message); | ||
| 1602 | } | ||
| 1603 | current = | ||
| 1604 | typeof current === "object" && "cause" in current | ||
| 1605 | ? (current as { cause: unknown }).cause | ||
| 1606 | : undefined; | ||
| 1513 | } | 1607 | } |
| 1514 | return String(cause); | 1608 | if (messages.length === 0) { |
| 1609 | return typeof cause === "string" | ||
| 1610 | ? cause | ||
| 1611 | : cause instanceof Error | ||
| 1612 | ? cause.message | ||
| 1613 | : String(cause); | ||
| 1614 | } | ||
| 1615 | return messages.join("\ncaused by: "); | ||
| 1515 | }; | 1616 | }; |
| 1516 | 1617 | ||
| 1517 | const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => { | 1618 | const readCliLogLevel = (argv: ReadonlyArray<string>): string | undefined => { |
| @@ -1802,11 +1903,9 @@ const runCallHelp = ( · 284 unmodified lines | |||
| 1802 | Effect.gen(function* () { | 1903 | Effect.gen(function* () { |
| 1803 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); | 1904 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); |
| 1804 | 1905 | ||
| 1805 | const connection = yield* resolveExecutorServerConnection({ | 1906 | const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName }; |
| 1806 | baseUrl: args.baseUrl, | 1907 | const connection = yield* resolveExecutorServerConnection(target); |
| 1807 | serverName: args.serverName, | 1908 | const client = yield* makeApiClient(connection, target); |
| 1808 | }); | ||
| 1809 | const client = yield* makeApiClient(connection); | ||
| 1810 | const tools = yield* client.tools.list({ query: {} }); | 1909 | const tools = yield* client.tools.list({ query: {} }); |
| 1811 | const toolPaths = tools.map((tool) => tool.address); | 1910 | const toolPaths = tools.map((tool) => tool.address); |
| 1812 | 1911 | ||
| @@ -1987,7 +2086,7 @@ const resumeCommand = Command.make( · 174 unmodified lines | |||
| 1987 | 2086 | ||
| 1988 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); | 2087 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); |
| 1989 | 2088 | ||
| 1990 | const client = yield* makeApiClient(connection); | 2089 | const client = yield* makeApiClient(connection, target); |
| 1991 | const result = yield* client.executions.resume({ | 2090 | const result = yield* client.executions.resume({ |
| 1992 | params: { executionId }, | 2091 | params: { executionId }, |
| 1993 | payload: { action, content: contentObj }, | 2092 | payload: { action, content: contentObj }, |
| @@ -2052,8 +2151,8 @@ const toolsSearchCommand = Command.make( · 58 unmodified lines | |||
| 2052 | }), | 2151 | }), |
| 2053 | ).pipe(Command.withDescription("Search tools by natural-language query")); | 2152 | ).pipe(Command.withDescription("Search tools by natural-language query")); |
| 2054 | 2153 | ||
| 2055 | const toolsSourcesCommand = Command.make( | 2154 | const toolsIntegrationsCommand = Command.make( |
| 2056 | "sources", | 2155 | "integrations", |
| 2057 | { | 2156 | { |
| 2058 | query: Options.string("query").pipe(Options.optional), | 2157 | query: Options.string("query").pipe(Options.optional), |
| 2059 | limit: Options.integer("limit").pipe(Options.withDefault(50)), | 2158 | limit: Options.integer("limit").pipe(Options.withDefault(50)), |
| @@ -2065,7 +2164,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | |||
| 2065 | Effect.gen(function* () { | 2164 | Effect.gen(function* () { |
| 2066 | applyScope(scope); | 2165 | applyScope(scope); |
| 2067 | const target = serverTargetFromOptions({ baseUrl, server }); | 2166 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 2068 | const code = buildListSourcesCode({ | 2167 | const code = buildListIntegrationsCode({ |
| 2069 | query: Option.getOrUndefined(query), | 2168 | query: Option.getOrUndefined(query), |
| 2070 | limit, | 2169 | limit, |
| 2071 | }); | 2170 | }); |
| @@ -2077,7 +2176,7 @@ const toolsSourcesCommand = Command.make( · 5 unmodified lines | |||
| 2077 | outcome: result.outcome, | 2176 | outcome: result.outcome, |
| 2078 | }); | 2177 | }); |
| 2079 | }), | 2178 | }), |
| 2080 | ).pipe(Command.withDescription("List configured sources and tool counts")); | 2179 | ).pipe(Command.withDescription("List configured integrations and tool counts")); |
| 2081 | 2180 | ||
| 2082 | const toolsDescribeCommand = Command.make( | 2181 | const toolsDescribeCommand = Command.make( |
| 2083 | "describe", | 2182 | "describe", |
| @@ -2102,21 +2201,55 @@ const toolsDescribeCommand = Command.make( · 18 unmodified lines | |||
| 2102 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); | 2201 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); |
| 2103 | 2202 | ||
| 2104 | const toolsCommand = Command.make("tools").pipe( | 2203 | const toolsCommand = Command.make("tools").pipe( |
| 2105 | Command.withSubcommands([toolsSearchCommand, toolsSourcesCommand, toolsDescribeCommand] as const), | 2204 | Command.withSubcommands([ |
| 2106 | Command.withDescription("Discover available tools and sources"), | 2205 | toolsSearchCommand, |
| 2206 | toolsIntegrationsCommand, | ||
| 2207 | toolsDescribeCommand, | ||
| 2208 | ] as const), | ||
| 2209 | Command.withDescription("Discover available tools and integrations"), | ||
| 2107 | ); | 2210 | ); |
| 2108 | 2211 | ||
| 2212 | const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; | ||
| 2213 | const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; | ||
| 2214 | |||
| 2215 | const parseHeaderEnvOption = ( | ||
| 2216 | headerEnv: Option.Option<Record<string, string>>, | ||
| 2217 | ): ExecutorServerHeaders | undefined => { | ||
| 2218 | const values = Option.getOrUndefined(headerEnv); | ||
| 2219 | if (!values) return undefined; | ||
| 2220 | const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {}; | ||
| 2221 | for (const [rawHeaderName, rawEnvName] of Object.entries(values)) { | ||
| 2222 | const headerName = rawHeaderName.trim(); | ||
| 2223 | const envName = rawEnvName.trim(); | ||
| 2224 | if (!HEADER_NAME_PATTERN.test(headerName)) { | ||
| 2225 | throw new Error( | ||
| 2226 | `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`, | ||
| 2227 | ); | ||
| 2228 | } | ||
| 2229 | if (!ENV_NAME_PATTERN.test(envName)) { | ||
| 2230 | throw new Error( | ||
| 2231 | `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`, | ||
| 2232 | ); | ||
| 2233 | } | ||
| 2234 | headers[headerName] = { kind: "env", name: envName }; | ||
| 2235 | } | ||
| 2236 | return Object.keys(headers).length > 0 ? headers : undefined; | ||
| 2237 | }; | ||
| 2238 | |||
| 2109 | const profileConnectionInput = (input: { | 2239 | const profileConnectionInput = (input: { |
| 2110 | readonly origin: string; | 2240 | readonly origin: string; |
| 2111 | readonly displayName: Option.Option<string>; | 2241 | readonly displayName: Option.Option<string>; |
| 2112 | readonly kind: Option.Option<"http" | "desktop-sidecar">; | 2242 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| 2243 | readonly headerEnv: Option.Option<Record<string, string>>; | ||
| 2113 | }): ExecutorServerConnectionInput => { | 2244 | }): ExecutorServerConnectionInput => { |
| 2114 | const selectedKind = Option.getOrUndefined(input.kind); | 2245 | const selectedKind = Option.getOrUndefined(input.kind); |
| 2115 | const displayName = Option.getOrUndefined(input.displayName); | 2246 | const displayName = Option.getOrUndefined(input.displayName); |
| 2247 | const headers = parseHeaderEnvOption(input.headerEnv); | ||
| 2116 | return { | 2248 | return { |
| 2117 | kind: selectedKind ?? "http", | 2249 | kind: selectedKind ?? "http", |
| 2118 | origin: input.origin, | 2250 | origin: input.origin, |
| 2119 | ...(displayName ? { displayName } : {}), | 2251 | ...(displayName ? { displayName } : {}), |
| 2252 | ...(headers ? { headers } : {}), | ||
| 2120 | }; | 2253 | }; |
| 2121 | }; | 2254 | }; |
| 2122 | 2255 | ||
| @@ -2136,13 +2269,16 @@ const printServerProfiles = () => · 13 unmodified lines | |||
| 2136 | origin: profile.connection.origin, | 2269 | origin: profile.connection.origin, |
| 2137 | displayName: profile.connection.displayName, | 2270 | displayName: profile.connection.displayName, |
| 2138 | auth: profile.connection.auth ? "stored-auth" : "env-auth", | 2271 | auth: profile.connection.auth ? "stored-auth" : "env-auth", |
| 2272 | headers: profile.connection.headers | ||
| 2273 | ? `${Object.keys(profile.connection.headers).length} header-env` | ||
| 2274 | : "no-headers", | ||
| 2139 | })); | 2275 | })); |
| 2140 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); | 2276 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| 2141 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); | 2277 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| 2142 | 2278 | ||
| 2143 | for (const row of rows) { | 2279 | for (const row of rows) { |
| 2144 | console.log( | 2280 | console.log( |
| 2145 | `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, | 2281 | `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`, |
| 2146 | ); | 2282 | ); |
| 2147 | } | 2283 | } |
| 2148 | }); | 2284 | }); |
| @@ -2160,17 +2296,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines | |||
| 2160 | Options.optional, | 2296 | Options.optional, |
| 2161 | Options.withDescription("Server kind. Defaults to http."), | 2297 | Options.withDescription("Server kind. Defaults to http."), |
| 2162 | ), | 2298 | ), |
| 2299 | headerEnv: Options.keyValuePair("header-env").pipe( | ||
| 2300 | Options.optional, | ||
| 2301 | Options.withDescription( | ||
| 2302 | "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.", | ||
| 2303 | ), | ||
| 2304 | ), | ||
| 2163 | makeDefault: Options.boolean("default").pipe( | 2305 | makeDefault: Options.boolean("default").pipe( |
| 2164 | Options.withDefault(false), | 2306 | Options.withDefault(false), |
| 2165 | Options.withDescription("Make this profile the default server."), | 2307 | Options.withDescription("Make this profile the default server."), |
| 2166 | ), | 2308 | ), |
| 2167 | }, | 2309 | }, |
| 2168 | ({ name, origin, displayName, kind, makeDefault }) => | 2310 | ({ name, origin, displayName, kind, headerEnv, makeDefault }) => |
| 2169 | Effect.gen(function* () { | 2311 | Effect.gen(function* () { |
| 2170 | const profileName = validateCliServerConnectionProfileName(name); | 2312 | const profileName = validateCliServerConnectionProfileName(name); |
| 2171 | const store = yield* upsertCliServerConnectionProfile({ | 2313 | const store = yield* upsertCliServerConnectionProfile({ |
| 2172 | name: profileName, | 2314 | name: profileName, |
| 2173 | connection: profileConnectionInput({ origin, displayName, kind }), | 2315 | connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), |
| 2174 | makeDefault, | 2316 | makeDefault, |
| 2175 | }); | 2317 | }); |
| 2176 | const profile = findCliServerConnectionProfile(store, profileName); | 2318 | const profile = findCliServerConnectionProfile(store, profileName); |
| @@ -2307,9 +2449,13 @@ const chooseLoginProfileName = ( · 130 unmodified lines | |||
| 2307 | } | 2449 | } |
| 2308 | }; | 2450 | }; |
| 2309 | 2451 | ||
| 2452 | /** Where `executor login` lands with no flags and no stored profiles. */ | ||
| 2453 | const DEFAULT_LOGIN_ORIGIN = "https://executor.sh"; | ||
| 2454 | |||
| 2310 | // Resolve which server a login/logout targets: an existing profile (--server | 2455 | // Resolve which server a login/logout targets: an existing profile (--server |
| 2311 | // or the default) or a bare origin (--base-url). The profile name is decided | 2456 | // or the default), a bare origin (--base-url), or hosted Executor when |
| 2312 | // later, from the authenticated account. | 2457 | // nothing is configured yet. The profile name is decided later, from the |
| 2458 | // authenticated account. | ||
| 2313 | const resolveLoginOrigin = (input: { | 2459 | const resolveLoginOrigin = (input: { |
| 2314 | readonly baseUrl: Option.Option<string>; | 2460 | readonly baseUrl: Option.Option<string>; |
| 2315 | readonly server: Option.Option<string>; | 2461 | readonly server: Option.Option<string>; |
| @@ -2337,11 +2483,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines | |||
| 2337 | const store = yield* readCliServerConnectionStore(); | 2483 | const store = yield* readCliServerConnectionStore(); |
| 2338 | const profile = defaultCliServerConnectionProfile(store); | 2484 | const profile = defaultCliServerConnectionProfile(store); |
| 2339 | if (profile) return { origin: profile.connection.origin, profile }; | 2485 | if (profile) return { origin: profile.connection.origin, profile }; |
| 2340 | return yield* Effect.fail( | 2486 | return { origin: DEFAULT_LOGIN_ORIGIN, profile: null }; |
| 2341 | new Error( | ||
| 2342 | "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.", | ||
| 2343 | ), | ||
| 2344 | ); | ||
| 2345 | }); | 2487 | }); |
| 2346 | 2488 | ||
| 2347 | const loginNameOption = Options.string("name").pipe( | 2489 | const loginNameOption = Options.string("name").pipe( |
| @@ -2366,12 +2508,21 @@ const loginCommand = Command.make( · 18 unmodified lines | |||
| 2366 | Effect.gen(function* () { | 2508 | Effect.gen(function* () { |
| 2367 | const target = yield* resolveLoginOrigin({ baseUrl, server }); | 2509 | const target = yield* resolveLoginOrigin({ baseUrl, server }); |
| 2368 | const explicitName = Option.getOrUndefined(name); | 2510 | const explicitName = Option.getOrUndefined(name); |
| 2511 | // The target may have been picked implicitly (default profile, or the | ||
| 2512 | // hosted fallback): say where the login is going before the device flow. | ||
| 2513 | console.log(`Signing in to ${target.origin}`); | ||
| 2514 | const targetProfile = target.profile; | ||
| 2515 | const headers = targetProfile | ||
| 2516 | ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe( | ||
| 2517 | Effect.mapError(toError), | ||
| 2518 | ) | ||
| 2519 | : {}; | ||
| 2369 | const discovery = yield* Effect.tryPromise({ | 2520 | const discovery = yield* Effect.tryPromise({ |
| 2370 | try: () => discoverCliLogin(target.origin), | 2521 | try: () => discoverCliLogin(target.origin, { headers }), |
| 2371 | catch: toError, | 2522 | catch: toError, |
| 2372 | }); | 2523 | }); |
| 2373 | const grant = yield* Effect.tryPromise({ | 2524 | const grant = yield* Effect.tryPromise({ |
| 2374 | try: () => requestDeviceCode(discovery), | 2525 | try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), |
| 2375 | catch: toError, | 2526 | catch: toError, |
| 2376 | }); | 2527 | }); |
| 2377 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; | 2528 | const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; |
| @@ -2382,7 +2533,7 @@ const loginCommand = Command.make( · 4 unmodified lines | |||
| 2382 | if (!noBrowser) openBrowser(verifyUrl); | 2533 | if (!noBrowser) openBrowser(verifyUrl); |
| 2383 | console.log("Waiting for you to approve in the browser..."); | 2534 | console.log("Waiting for you to approve in the browser..."); |
| 2384 | const tokens = yield* Effect.tryPromise({ | 2535 | const tokens = yield* Effect.tryPromise({ |
| 2385 | try: () => pollForDeviceTokens(discovery, grant), | 2536 | try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), |
| 2386 | catch: toError, | 2537 | catch: toError, |
| 2387 | }); | 2538 | }); |
| 2388 | 2539 | ||
| @@ -2407,6 +2558,9 @@ const loginCommand = Command.make( · 18 unmodified lines | |||
| 2407 | kind: "http", | 2558 | kind: "http", |
| 2408 | origin: target.origin, | 2559 | origin: target.origin, |
| 2409 | ...(email ? { displayName: email } : {}), | 2560 | ...(email ? { displayName: email } : {}), |
| 2561 | ...(target.profile?.connection.headers | ||
| 2562 | ? { headers: target.profile.connection.headers } | ||
| 2563 | : {}), | ||
| 2410 | auth: { | 2564 | auth: { |
| 2411 | kind: "oauth", | 2565 | kind: "oauth", |
| 2412 | accessToken: tokens.accessToken, | 2566 | accessToken: tokens.accessToken, |
| @@ -2420,12 +2574,24 @@ const loginCommand = Command.make( · 7 unmodified lines | |||
| 2420 | }); | 2574 | }); |
| 2421 | 2575 | ||
| 2422 | console.log(""); | 2576 | console.log(""); |
| 2423 | console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`); | 2577 | // Profile bookkeeping stays backstage unless the user works with named |
| 2578 | // profiles (--name, or a store that already has several). | ||
| 2579 | const mentionProfile = | ||
| 2580 | explicitName !== undefined || (yield* readCliServerConnectionStore()).profiles.length > 1; | ||
| 2581 | console.log( | ||
| 2582 | mentionProfile | ||
| 2583 | ? `Logged in to ${target.origin} (profile "${profileName}", now the default).` | ||
| 2584 | : `Logged in to ${target.origin}.`, | ||
| 2585 | ); | ||
| 2424 | if (email) console.log(`Account: ${email}`); | 2586 | if (email) console.log(`Account: ${email}`); |
| 2425 | if (org) console.log(`Organization: ${org}`); | 2587 | if (org) console.log(`Organization: ${org}`); |
| 2426 | else if (sub) console.log(`User: ${sub}`); | 2588 | else if (sub) console.log(`User: ${sub}`); |
| 2427 | }), | 2589 | }), |
| 2428 | ).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)")); | 2590 | ).pipe( |
| 2591 | Command.withDescription( | ||
| 2592 | "Sign in to a hosted Executor server in the browser (device flow). Defaults to https://executor.sh.", | ||
| 2593 | ), | ||
| 2594 | ); | ||
| 2429 | 2595 | ||
| 2430 | const logoutCommand = Command.make( | 2596 | const logoutCommand = Command.make( |
| 2431 | "logout", | 2597 | "logout", |
| @@ -2441,8 +2607,14 @@ const logoutCommand = Command.make( · 9 unmodified lines | |||
| 2441 | console.log(`No stored login for ${target.origin}.`); | 2607 | console.log(`No stored login for ${target.origin}.`); |
| 2442 | return; | 2608 | return; |
| 2443 | } | 2609 | } |
| 2610 | // Profile bookkeeping stays backstage unless the user addressed one. | ||
| 2611 | const mentionProfile = Option.isSome(server) || store.profiles.length > 1; | ||
| 2444 | if (!profile.connection.auth) { | 2612 | if (!profile.connection.auth) { |
| 2445 | console.log(`Profile "${profile.name}" has no stored credentials.`); | 2613 | console.log( |
| 2614 | mentionProfile | ||
| 2615 | ? `Profile "${profile.name}" has no stored credentials.` | ||
| 2616 | : `Not signed in to ${target.origin}.`, | ||
| 2617 | ); | ||
| 2446 | return; | 2618 | return; |
| 2447 | } | 2619 | } |
| 2448 | yield* upsertCliServerConnectionProfile({ | 2620 | yield* upsertCliServerConnectionProfile({ |
| @@ -2451,11 +2623,14 @@ const logoutCommand = Command.make( · 2 unmodified lines | |||
| 2451 | kind: profile.connection.kind, | 2623 | kind: profile.connection.kind, |
| 2452 | origin: profile.connection.origin, | 2624 | origin: profile.connection.origin, |
| 2453 | displayName: profile.connection.displayName, | 2625 | displayName: profile.connection.displayName, |
| 2626 | ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), | ||
| 2454 | }, | 2627 | }, |
| 2455 | makeDefault: store.defaultProfile === profile.name, | 2628 | makeDefault: store.defaultProfile === profile.name, |
| 2456 | }); | 2629 | }); |
| 2457 | console.log( | 2630 | console.log( |
| 2458 | `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`, | 2631 | mentionProfile |
| 2632 | ? `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").` | ||
| 2633 | : `Logged out of ${profile.connection.origin}.`, | ||
| 2459 | ); | 2634 | ); |
| 2460 | }), | 2635 | }), |
| 2461 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); | 2636 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); |
| @@ -2709,11 +2884,25 @@ const mcpCommand = Command.make( · 247 unmodified lines | |||
| 2709 | "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", | 2884 | "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", |
| 2710 | ), | 2885 | ), |
| 2711 | ), | 2886 | ), |
| 2887 | noArtifacts: Options.boolean("no-artifacts") | ||
| 2888 | .pipe(Options.withDefault(false)) | ||
| 2889 | .pipe( | ||
| 2890 | Options.withDescription( | ||
| 2891 | "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.", | ||
| 2892 | ), | ||
| 2893 | ), | ||
| 2894 | searchTools: Options.boolean("search-tools") | ||
| 2895 | .pipe(Options.withDefault(false)) | ||
| 2896 | .pipe( | ||
| 2897 | Options.withDescription( | ||
| 2898 | "Serve one search_<integration> tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.", | ||
| 2899 | ), | ||
| 2900 | ), | ||
| 2712 | }, | 2901 | }, |
| 2713 | ({ scope, elicitationMode }) => | 2902 | ({ scope, elicitationMode, noArtifacts, searchTools }) => |
| 2714 | Effect.gen(function* () { | 2903 | Effect.gen(function* () { |
| 2715 | applyScope(scope); | 2904 | applyScope(scope); |
| 2716 | yield* runStdioMcpSession({ elicitationMode }); | 2905 | yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); |
| 2717 | }), | 2906 | }), |
| 2718 | ).pipe(Command.withDescription("Start an MCP server over stdio")); | 2907 | ).pipe(Command.withDescription("Start an MCP server over stdio")); |
| 2719 | 2908 | ||
| @@ -3164,7 +3353,12 @@ const program = ( · 444 unmodified lines | |||
| 3164 | } else { | 3353 | } else { |
| 3165 | console.error(renderCliError(cause)); | 3354 | console.error(renderCliError(cause)); |
| 3166 | } | 3355 | } |
| 3167 | process.exitCode = 1; | 3356 | // Exit hard, not via exitCode: this handler converts the failure into a |
| 3357 | // success, so runMain's teardown never force-exits, and background fibers | ||
| 3358 | // (the integrations-refresh fork) keep the event loop alive. A daemon | ||
| 3359 | // that failed boot then lingers with its port unbound, and the desktop | ||
| 3360 | // waits on the ready sentinel forever (issue #1403). | ||
| 3361 | process.exit(1); | ||
| 3168 | }), | 3362 | }), |
| 3169 | ), | 3363 | ), |
| 3170 | ); | 3364 | ); |
| @@ -1,11 +1,10 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Native-binding bootstrap for the `bun build --compile` binary. | 2 | // Native-binding bootstrap for the `bun build --compile` binary. |
| 3 | // | 3 | // |
| 4 | // `bun --compile` bundles JS into bunfs but does NOT include `.node` native | 4 | // `bun --compile` bundles JS into bunfs but does NOT include native binaries, |
| 5 | // addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside | 5 | // wasm sidecars, or runtime-read package files. build.ts copies those artifacts |
| 6 | // the binary fails. build.ts copies each platform's `.node` next to the | 6 | // next to the executable; here we publish their on-disk paths via env vars the |
| 7 | // executable (`libsql.node`, `keyring.node`); here we publish their on-disk | 7 | // loaders read. |
| 8 | // paths via env vars the loaders read. | ||
| 9 | // | 8 | // |
| 10 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import | 9 | // This MUST be the FIRST import in main.ts. ES modules evaluate every import |
| 11 | // before the importer's own body, and libSQL resolves its native addon EAGERLY | 10 | // before the importer's own body, and libSQL resolves its native addon EAGERLY |
| @@ -18,6 +17,8 @@ · 6 unmodified lines | |||
| 18 | import { existsSync } from "node:fs"; | 17 | import { existsSync } from "node:fs"; |
| 19 | import { dirname, join } from "node:path"; | 18 | import { dirname, join } from "node:path"; |
| 20 | 19 | ||
| 20 | import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; | ||
| 21 | |||
| 21 | const execDir = dirname(process.execPath); | 22 | const execDir = dirname(process.execPath); |
| 22 | 23 | ||
| 23 | // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the | 24 | // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the |
| @@ -43,3 +44,33 @@ if ( · 19 unmodified lines | |||
| 43 | ) { | 44 | ) { |
| 44 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; | 45 | process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; |
| 45 | } | 46 | } |
| 47 | |||
| 48 | const workerdOnDisk = join(execDir, process.platform === "win32" ? "workerd.exe" : "workerd"); | ||
| 49 | if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSync(workerdOnDisk)) { | ||
| 50 | process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk; | ||
| 51 | } | ||
| 52 | |||
| 53 | // worker-bundler: the compiled binary cannot resolve `@cloudflare/worker-bundler` | ||
| 54 | // by name (bunfs has no node_modules), so build.ts stages the package's dist | ||
| 55 | // beside the executable and we publish its path here. An absent directory is | ||
| 56 | // normal off the packaged path (dev, `bun run`), so that stays quiet — but a | ||
| 57 | // directory that is PRESENT AND INCOMPLETE is a broken install, and swallowing | ||
| 58 | // it is what turns a packaging slip into an unresolvable bare import at | ||
| 59 | // startup. Report it on stderr instead of failing open. | ||
| 60 | const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME); | ||
| 61 | if ( | ||
| 62 | typeof Bun !== "undefined" && | ||
| 63 | !process.env.EXECUTOR_WORKER_BUNDLER_DIR && | ||
| 64 | existsSync(workerBundlerOnDisk) | ||
| 65 | ) { | ||
| 66 | const missing = missingWorkerBundlerFiles(workerBundlerOnDisk, existsSync); | ||
| 67 | if (missing.length === 0) { | ||
| 68 | process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; | ||
| 69 | } else { | ||
| 70 | process.stderr.write( | ||
| 71 | `executor: the bundled Worker toolchain at ${workerBundlerOnDisk} is incomplete ` + | ||
| 72 | `(missing ${missing.join(", ")}). Features that build Workers will be unavailable; ` + | ||
| 73 | `reinstall or update executor to repair it.\n`, | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | } | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 3 | import { | 5 | import { |
| 4 | canAutoStartCliServerConnection, | 6 | canAutoStartCliServerConnection, |
| 5 | chooseCliServerConnectionWithActiveLocal, | 7 | chooseCliServerConnectionWithActiveLocal, |
| 8 | describeUnauthorizedCliServer, | ||
| 6 | parseCliExecutorServerConnection, | 9 | parseCliExecutorServerConnection, |
| 7 | withCliServerAuthFallback, | 10 | withCliServerAuthFallback, |
| 8 | } from "./server-connection"; | 11 | } from "./server-connection"; |
| @@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines | |||
| 135 | ).toBe("conflict"); | 138 | ).toBe("conflict"); |
| 136 | }); | 139 | }); |
| 137 | }); | 140 | }); |
| 141 | |||
| 142 | describe("describeUnauthorizedCliServer", () => { | ||
| 143 | it("hints plain `executor login` when the server was picked implicitly", () => { | ||
| 144 | const connection = normalizeExecutorServerConnection({ | ||
| 145 | key: "profile:rhys-executor.sh", | ||
| 146 | origin: "https://executor.sh", | ||
| 147 | auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 }, | ||
| 148 | }); | ||
| 149 | |||
| 150 | const message = describeUnauthorizedCliServer({ | ||
| 151 | connection, | ||
| 152 | cliPrefix: "executor", | ||
| 153 | target: {}, | ||
| 154 | }); | ||
| 155 | expect(message).toContain("You're signed out of https://executor.sh"); | ||
| 156 | expect(message).toContain("Run `executor login` to sign in again."); | ||
| 157 | // Profile names are plumbing: never surfaced when the user didn't type one. | ||
| 158 | expect(message).not.toContain("--server"); | ||
| 159 | expect(message).not.toContain("profile"); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("echoes --server back when the user targeted a named profile", () => { | ||
| 163 | const connection = normalizeExecutorServerConnection({ | ||
| 164 | key: "profile:work", | ||
| 165 | origin: "https://executor.example", | ||
| 166 | auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 }, | ||
| 167 | }); | ||
| 168 | |||
| 169 | const message = describeUnauthorizedCliServer({ | ||
| 170 | connection, | ||
| 171 | cliPrefix: "executor", | ||
| 172 | target: { serverName: "work" }, | ||
| 173 | }); | ||
| 174 | expect(message).toContain("You're signed out of https://executor.example"); | ||
| 175 | expect(message).toContain("executor login --server work"); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("echoes --base-url back when the user targeted an origin", () => { | ||
| 179 | const connection = normalizeExecutorServerConnection({ | ||
| 180 | origin: "https://executor.example", | ||
| 181 | }); | ||
| 182 | |||
| 183 | const message = describeUnauthorizedCliServer({ | ||
| 184 | connection, | ||
| 185 | cliPrefix: "executor", | ||
| 186 | target: { baseUrl: "https://executor.example" }, | ||
| 187 | }); | ||
| 188 | expect(message).toContain("no credentials are stored"); | ||
| 189 | expect(message).toContain("executor login --base-url https://executor.example"); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("mentions the env vars when a profile-less bearer key is rejected", () => { | ||
| 193 | const connection = normalizeExecutorServerConnection({ | ||
| 194 | origin: "https://executor.example", | ||
| 195 | auth: { kind: "bearer", token: "key_bad" }, | ||
| 196 | }); | ||
| 197 | |||
| 198 | const message = describeUnauthorizedCliServer({ | ||
| 199 | connection, | ||
| 200 | cliPrefix: "executor", | ||
| 201 | target: {}, | ||
| 202 | }); | ||
| 203 | expect(message).toContain("rejected the stored bearer credentials"); | ||
| 204 | expect(message).toContain("EXECUTOR_API_KEY"); | ||
| 205 | }); | ||
| 206 | |||
| 207 | it("uses the dev entrypoint prefix verbatim", () => { | ||
| 208 | const connection = normalizeExecutorServerConnection({ | ||
| 209 | key: "profile:hosted", | ||
| 210 | origin: "https://executor.example", | ||
| 211 | auth: { kind: "oauth", accessToken: "stale" }, | ||
| 212 | }); | ||
| 213 | |||
| 214 | const message = describeUnauthorizedCliServer({ | ||
| 215 | connection, | ||
| 216 | cliPrefix: "bun run apps/cli/src/main.ts", | ||
| 217 | target: {}, | ||
| 218 | }); | ||
| 219 | expect(message).toContain("bun run apps/cli/src/main.ts login"); | ||
| 220 | }); | ||
| 221 | }); | ||
| @@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines | |||
| 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| 53 | }; | 53 | }; |
| 54 | 54 | ||
| 55 | export const profileNameFromConnectionKey = (key: string): string | null => | ||
| 56 | key.startsWith("profile:") ? key.slice("profile:".length) : null; | ||
| 57 | |||
| 58 | // A 401 from the server means the stored credential is stale, revoked, or | ||
| 59 | // missing: a sign-in problem, not a transport problem. Rendered instead of | ||
| 60 | // the raw HTTP client error so the user learns the recovery command, not the | ||
| 61 | // failing endpoint. | ||
| 62 | // | ||
| 63 | // The hint mirrors how the user addressed the server. The common case types | ||
| 64 | // no flags (the default profile, normally executor.sh), and login re-targets | ||
| 65 | // that same default, so the fix is plain `executor login`: profile names are | ||
| 66 | // plumbing and stay invisible. Only an explicit --server / --base-url comes | ||
| 67 | // back in the hint. | ||
| 68 | export const describeUnauthorizedCliServer = (input: { | ||
| 69 | readonly connection: ExecutorServerConnection; | ||
| 70 | readonly cliPrefix: string; | ||
| 71 | readonly target: { readonly baseUrl?: string; readonly serverName?: string }; | ||
| 72 | }): string => { | ||
| 73 | const { connection, cliPrefix, target } = input; | ||
| 74 | const loginCommand = target.serverName | ||
| 75 | ? `${cliPrefix} login --server ${target.serverName}` | ||
| 76 | : target.baseUrl | ||
| 77 | ? `${cliPrefix} login --base-url ${connection.origin}` | ||
| 78 | : `${cliPrefix} login`; | ||
| 79 | |||
| 80 | if (!connection.auth) { | ||
| 81 | return [ | ||
| 82 | `${connection.origin} requires authentication, and no credentials are stored for it.`, | ||
| 83 | `Run \`${loginCommand}\` to sign in.`, | ||
| 84 | ].join("\n"); | ||
| 85 | } | ||
| 86 | |||
| 87 | if (connection.auth.kind === "oauth") { | ||
| 88 | return [ | ||
| 89 | `You're signed out of ${connection.origin}.`, | ||
| 90 | `Run \`${loginCommand}\` to sign in again.`, | ||
| 91 | ].join("\n"); | ||
| 92 | } | ||
| 93 | |||
| 94 | const envHint = | ||
| 95 | connection.auth.kind === "bearer" && !profileNameFromConnectionKey(connection.key) | ||
| 96 | ? " If the key came from EXECUTOR_API_KEY or EXECUTOR_AUTH_TOKEN, check that value." | ||
| 97 | : ""; | ||
| 98 | return [ | ||
| 99 | `${connection.origin} rejected the stored ${connection.auth.kind} credentials (401).${envHint}`, | ||
| 100 | `Run \`${loginCommand}\` to sign in again.`, | ||
| 101 | ].join("\n"); | ||
| 102 | }; | ||
| 103 | |||
| 55 | export type CliServerConnectionSource = | 104 | export type CliServerConnectionSource = |
| 56 | | "explicit" | 105 | | "explicit" |
| 57 | | "default-profile" | 106 | | "default-profile" |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { BunServices } from "@effect/platform-bun"; | 2 | import { BunServices } from "@effect/platform-bun"; |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | 3 | import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -25,7 +25,7 @@ afterEach(() => { · 18 unmodified lines | |||
| 25 | }); | 25 | }); |
| 26 | 26 | ||
| 27 | describe("CLI server connection profiles", () => { | 27 | describe("CLI server connection profiles", () => { |
| 28 | it("round-trips named server connections and default selection", () => | 28 | it.effect("round-trips named server connections and default selection", () => |
| 29 | Effect.gen(function* () { | 29 | Effect.gen(function* () { |
| 30 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); | 30 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 31 | process.env.EXECUTOR_DATA_DIR = dataDir; | 31 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 4 unmodified lines | |||
| 36 | connection: { | 36 | connection: { |
| 37 | origin: "https://executor.example/api", | 37 | origin: "https://executor.example/api", |
| 38 | auth: { kind: "bearer", token: "key_123" }, | 38 | auth: { kind: "bearer", token: "key_123" }, |
| 39 | headers: { | ||
| 40 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 41 | }, | ||
| 39 | }, | 42 | }, |
| 40 | makeDefault: true, | 43 | makeDefault: true, |
| 41 | }); | 44 | }); |
| @@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines | |||
| 50 | kind: "bearer", | 53 | kind: "bearer", |
| 51 | token: "key_123", | 54 | token: "key_123", |
| 52 | }); | 55 | }); |
| 56 | expect(store.profiles[0]?.connection.headers).toEqual({ | ||
| 57 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 58 | }); | ||
| 53 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); | 59 | expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); |
| 54 | } finally { | 60 | } finally { |
| 55 | rmSync(dataDir, { recursive: true, force: true }); | 61 | rmSync(dataDir, { recursive: true, force: true }); |
| 56 | } | 62 | } |
| 57 | }).pipe(Effect.provide(BunServices.layer))); | 63 | }).pipe(Effect.provide(BunServices.layer)), |
| 64 | ); | ||
| 58 | 65 | ||
| 59 | it("switches and removes the default profile", () => | 66 | it.effect("switches and removes the default profile", () => |
| 60 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 61 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); | 68 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); |
| 62 | process.env.EXECUTOR_DATA_DIR = dataDir; | 69 | process.env.EXECUTOR_DATA_DIR = dataDir; |
| @@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { · 19 unmodified lines | |||
| 82 | } finally { | 89 | } finally { |
| 83 | rmSync(dataDir, { recursive: true, force: true }); | 90 | rmSync(dataDir, { recursive: true, force: true }); |
| 84 | } | 91 | } |
| 85 | }).pipe(Effect.provide(BunServices.layer))); | 92 | }).pipe(Effect.provide(BunServices.layer)), |
| 93 | ); | ||
| 86 | 94 | ||
| 87 | it("drops malformed profiles when parsing", () => { | 95 | it("drops malformed profiles when parsing", () => { |
| 88 | const store = parseCliServerConnectionStore( | 96 | const store = parseCliServerConnectionStore( |
| @@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { · 25 unmodified lines | |||
| 114 | key: "desktop-sidecar", | 122 | key: "desktop-sidecar", |
| 115 | origin: "http://127.0.0.1:4789", | 123 | origin: "http://127.0.0.1:4789", |
| 116 | auth: { kind: "basic", username: "executor", password: "secret" }, | 124 | auth: { kind: "basic", username: "executor", password: "secret" }, |
| 125 | headers: { | ||
| 126 | "CF-Access-Client-Id": { | ||
| 127 | kind: "env", | ||
| 128 | name: "EXECUTOR_CF_ACCESS_CLIENT_ID", | ||
| 129 | }, | ||
| 130 | }, | ||
| 117 | }, | 131 | }, |
| 118 | }, | 132 | }, |
| 119 | ], | 133 | ], |
| @@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines | |||
| 127 | username: "executor", | 141 | username: "executor", |
| 128 | password: "secret", | 142 | password: "secret", |
| 129 | }); | 143 | }); |
| 144 | expect(store.profiles[0]?.connection.headers).toEqual({ | ||
| 145 | "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, | ||
| 146 | }); | ||
| 130 | }); | 147 | }); |
| 148 | |||
| 149 | // ------------------------------------------------------------------------- | ||
| 150 | // File permissions. | ||
| 151 | // | ||
| 152 | // This store holds live credentials for a hosted server — a bearer token, or | ||
| 153 | // an OAuth access token and its long-lived refresh token. Nothing pinned its | ||
| 154 | // mode before, so it was created with the process umask (0644 by default) and | ||
| 155 | // readable by every other account on the machine. | ||
| 156 | // | ||
| 157 | // Both cases are covered because they need different mechanisms: `mode` on | ||
| 158 | // create, and a `chmod` for the overwrite. The overwrite is the common path | ||
| 159 | // here, since the store is rewritten on every silent token refresh. | ||
| 160 | // ------------------------------------------------------------------------- | ||
| 161 | |||
| 162 | it.effect("creates the credential store owner-only", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-mode-")); | ||
| 165 | process.env.EXECUTOR_DATA_DIR = dataDir; | ||
| 166 | |||
| 167 | try { | ||
| 168 | yield* upsertCliServerConnectionProfile({ | ||
| 169 | name: "remote", | ||
| 170 | connection: { | ||
| 171 | origin: "https://executor.example", | ||
| 172 | auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, | ||
| 173 | }, | ||
| 174 | makeDefault: true, | ||
| 175 | }); | ||
| 176 | |||
| 177 | const mode = statSync(join(dataDir, "server-connections.json")).mode & 0o777; | ||
| 178 | expect(mode).toBe(0o600); | ||
| 179 | } finally { | ||
| 180 | rmSync(dataDir, { recursive: true, force: true }); | ||
| 181 | } | ||
| 182 | }).pipe(Effect.provide(BunServices.layer)), | ||
| 183 | ); | ||
| 184 | |||
| 185 | it.effect("tightens a pre-existing world-readable credential store on rewrite", () => | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-chmod-")); | ||
| 188 | process.env.EXECUTOR_DATA_DIR = dataDir; | ||
| 189 | const storePath = join(dataDir, "server-connections.json"); | ||
| 190 | |||
| 191 | try { | ||
| 192 | // A store left behind by an older version, world-readable. `mode` on | ||
| 193 | // write is ignored for an existing file, so only the chmod fixes this. | ||
| 194 | writeFileSync(storePath, JSON.stringify({ version: 1, profiles: [] })); | ||
| 195 | chmodSync(storePath, 0o644); | ||
| 196 | |||
| 197 | yield* upsertCliServerConnectionProfile({ | ||
| 198 | name: "remote", | ||
| 199 | connection: { | ||
| 200 | origin: "https://executor.example", | ||
| 201 | auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, | ||
| 202 | }, | ||
| 203 | makeDefault: true, | ||
| 204 | }); | ||
| 205 | |||
| 206 | expect(statSync(storePath).mode & 0o777).toBe(0o600); | ||
| 207 | } finally { | ||
| 208 | rmSync(dataDir, { recursive: true, force: true }); | ||
| 209 | } | ||
| 210 | }).pipe(Effect.provide(BunServices.layer)), | ||
| 211 | ); | ||
| 131 | }); | 212 | }); |
| @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines | |||
| 62 | }), | 62 | }), |
| 63 | ]); | 63 | ]); |
| 64 | 64 | ||
| 65 | const PersistedHeader = Schema.Struct({ | ||
| 66 | kind: Schema.Literal("env"), | ||
| 67 | name: Schema.String, | ||
| 68 | }); | ||
| 69 | |||
| 65 | const PersistedConnection = Schema.Struct({ | 70 | const PersistedConnection = Schema.Struct({ |
| 66 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), | 71 | kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), |
| 67 | key: Schema.optional(Schema.String), | 72 | key: Schema.optional(Schema.String), |
| @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line | |||
| 69 | apiBaseUrl: Schema.optional(Schema.String), | 74 | apiBaseUrl: Schema.optional(Schema.String), |
| 70 | displayName: Schema.optional(Schema.String), | 75 | displayName: Schema.optional(Schema.String), |
| 71 | auth: Schema.optional(PersistedAuth), | 76 | auth: Schema.optional(PersistedAuth), |
| 77 | headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), | ||
| 72 | }); | 78 | }); |
| 73 | 79 | ||
| 74 | const PersistedProfile = Schema.Struct({ | 80 | const PersistedProfile = Schema.Struct({ |
| @@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( · 69 unmodified lines | |||
| 144 | const path = yield* Path.Path; | 150 | const path = yield* Path.Path; |
| 145 | const dataDir = resolveDataDir(path); | 151 | const dataDir = resolveDataDir(path); |
| 146 | yield* fs.makeDirectory(dataDir, { recursive: true }); | 152 | yield* fs.makeDirectory(dataDir, { recursive: true }); |
| 147 | yield* fs.writeFileString( | 153 | const storePath = serverConnectionStorePath(path); |
| 148 | serverConnectionStorePath(path), | 154 | // This store holds live credentials for a hosted server — a bearer token, or |
| 149 | serializeCliServerConnectionStore(store), | 155 | // an OAuth access token AND its long-lived refresh token — so create it |
| 150 | ); | 156 | // owner-only, exactly as the local-server manifest does for the sibling |
| 157 | // secret it keeps under `server-control/`. | ||
| 158 | // | ||
| 159 | // Both steps are needed, and the second matters more here than it does | ||
| 160 | // there. `mode` applies only when the file is CREATED, so it closes the | ||
| 161 | // window where a fresh store is briefly world-readable. The `chmod` covers | ||
| 162 | // overwriting a store that already exists with looser permissions — and this | ||
| 163 | // file is rewritten on every silent token refresh, so overwrite is the | ||
| 164 | // common path, not the rare one. | ||
| 165 | yield* fs.writeFileString(storePath, serializeCliServerConnectionStore(store), { | ||
| 166 | mode: 0o600, | ||
| 167 | }); | ||
| 168 | yield* fs.chmod(storePath, 0o600).pipe(Effect.ignore); | ||
| 151 | }); | 169 | }); |
| 152 | 170 | ||
| 153 | export const upsertCliServerConnectionProfile = (input: { | 171 | export const upsertCliServerConnectionProfile = (input: { |
| @@ -185,6 +185,36 @@ describe("service unit generation", () => { · 184 unmodified lines | |||
| 185 | expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); | 185 | expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); |
| 186 | }); | 186 | }); |
| 187 | 187 | ||
| 188 | it("preserves custom TLS trust paths in the supervised environment", () => { | ||
| 189 | const previousNodeExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; | ||
| 190 | const previousSslCertFile = process.env.SSL_CERT_FILE; | ||
| 191 | const previousSslCertDir = process.env.SSL_CERT_DIR; | ||
| 192 | process.env.NODE_EXTRA_CA_CERTS = "/Users/x/.certs/node-extra.pem"; | ||
| 193 | process.env.SSL_CERT_FILE = "/Users/x/.certs/combined.pem"; | ||
| 194 | process.env.SSL_CERT_DIR = "/Users/x/.certs"; | ||
| 195 | |||
| 196 | const wrapper = generateWindowsDaemonWrapper( | ||
| 197 | { | ||
| 198 | executablePath: "C:\\Program Files\\Executor\\executor.exe", | ||
| 199 | port: 4789, | ||
| 200 | version: "1.5.10", | ||
| 201 | }, | ||
| 202 | "C:\\Users\\x\\.executor", | ||
| 203 | "C:\\Users\\x\\.executor\\logs", | ||
| 204 | ); | ||
| 205 | |||
| 206 | if (previousNodeExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; | ||
| 207 | else process.env.NODE_EXTRA_CA_CERTS = previousNodeExtraCaCerts; | ||
| 208 | if (previousSslCertFile === undefined) delete process.env.SSL_CERT_FILE; | ||
| 209 | else process.env.SSL_CERT_FILE = previousSslCertFile; | ||
| 210 | if (previousSslCertDir === undefined) delete process.env.SSL_CERT_DIR; | ||
| 211 | else process.env.SSL_CERT_DIR = previousSslCertDir; | ||
| 212 | |||
| 213 | expect(wrapper).toContain('set "NODE_EXTRA_CA_CERTS=/Users/x/.certs/node-extra.pem"'); | ||
| 214 | expect(wrapper).toContain('set "SSL_CERT_FILE=/Users/x/.certs/combined.pem"'); | ||
| 215 | expect(wrapper).toContain('set "SSL_CERT_DIR=/Users/x/.certs"'); | ||
| 216 | }); | ||
| 217 | |||
| 188 | it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { | 218 | it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { |
| 189 | // A `"` in PATH would close the `set "PATH=..."` quote early and let a | 219 | // A `"` in PATH would close the `set "PATH=..."` quote early and let a |
| 190 | // `& cmd &` fragment run at boot as the user; strip it (illegal in a path | 220 | // `& cmd &` fragment run at boot as the user; strip it (illegal in a path |
| @@ -177,6 +177,17 @@ const serviceEnvironment = ( · 176 unmodified lines | |||
| 177 | "EXECUTOR_SENTRY_RELEASE", | 177 | "EXECUTOR_SENTRY_RELEASE", |
| 178 | "EXECUTOR_SENTRY_ENVIRONMENT", | 178 | "EXECUTOR_SENTRY_ENVIRONMENT", |
| 179 | "EXECUTOR_RUN_ID", | 179 | "EXECUTOR_RUN_ID", |
| 180 | // Analytics opt-out must survive into the supervised unit's minimal env, | ||
| 181 | // or an opted-out install would silently re-enable analytics under launchd. | ||
| 182 | "DO_NOT_TRACK", | ||
| 183 | "EXECUTOR_DISABLE_ANALYTICS", | ||
| 184 | // Preserve the caller's TLS trust configuration. Corporate/intercepting | ||
| 185 | // CAs commonly live outside the OS trust store; dropping these paths in a | ||
| 186 | // minimal launchd/systemd environment makes HTTPS integrations fail only | ||
| 187 | // after service installation while the same CLI call succeeds. | ||
| 188 | "NODE_EXTRA_CA_CERTS", | ||
| 189 | "SSL_CERT_FILE", | ||
| 190 | "SSL_CERT_DIR", | ||
| 180 | ] as const; | 191 | ] as const; |
| 181 | const passThrough = Object.fromEntries( | 192 | const passThrough = Object.fromEntries( |
| 182 | passThroughKeys.flatMap((key) => { | 193 | passThroughKeys.flatMap((key) => { |
| @@ -408,14 +408,14 @@ export const buildSearchToolsCode = (input: { · 407 unmodified lines | |||
| 408 | return `return await tools.search(${JSON.stringify(payload)});`; | 408 | return `return await tools.search(${JSON.stringify(payload)});`; |
| 409 | }; | 409 | }; |
| 410 | 410 | ||
| 411 | export const buildListSourcesCode = (input: { query?: string; limit: number }): string => { | 411 | export const buildListIntegrationsCode = (input: { query?: string; limit: number }): string => { |
| 412 | const payload: Record<string, unknown> = { | 412 | const payload: Record<string, unknown> = { |
| 413 | limit: input.limit, | 413 | limit: input.limit, |
| 414 | }; | 414 | }; |
| 415 | if (input.query && input.query.trim().length > 0) { | 415 | if (input.query && input.query.trim().length > 0) { |
| 416 | payload.query = input.query; | 416 | payload.query = input.query; |
| 417 | } | 417 | } |
| 418 | return `return await tools.executor.sources.list(${JSON.stringify(payload)});`; | 418 | return `return await tools.executor.integrations.list(${JSON.stringify(payload)});`; |
| 419 | }; | 419 | }; |
| 420 | 420 | ||
| 421 | export const buildDescribeToolCode = (toolPath: string): string => | 421 | export const buildDescribeToolCode = (toolPath: string): string => |
| @@ -0,0 +1,59 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { join } from "node:path"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | WORKER_BUNDLER_DIRNAME, | ||
| 6 | WORKER_BUNDLER_RUNTIME_FILES, | ||
| 7 | missingWorkerBundlerFiles, | ||
| 8 | } from "./worker-bundler-artifact"; | ||
| 9 | |||
| 10 | const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME); | ||
| 11 | |||
| 12 | /** An existence probe that reports only the given POSIX-relative paths. */ | ||
| 13 | const staged = | ||
| 14 | (...relatives: readonly string[]) => | ||
| 15 | (path: string): boolean => | ||
| 16 | relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/"))); | ||
| 17 | |||
| 18 | describe("missingWorkerBundlerFiles", () => { | ||
| 19 | it("reports nothing when the full artifact is staged", () => { | ||
| 20 | expect(missingWorkerBundlerFiles(STAGED_DIR, staged(...WORKER_BUNDLER_RUNTIME_FILES))).toEqual( | ||
| 21 | [], | ||
| 22 | ); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("reports every file when the directory is empty", () => { | ||
| 26 | expect(missingWorkerBundlerFiles(STAGED_DIR, () => false)).toEqual([ | ||
| 27 | ...WORKER_BUNDLER_RUNTIME_FILES, | ||
| 28 | ]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | // The regression this whole seam exists for: the packaged daemon reads the | ||
| 32 | // wasm from disk beside the entry. A copy that brought the JS but dropped the | ||
| 33 | // wasm used to pass the build and fail on the user's machine at startup. | ||
| 34 | it("reports the esbuild wasm when only the JS entrypoints are staged", () => { | ||
| 35 | expect( | ||
| 36 | missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/index.bundled.js")), | ||
| 37 | ).toEqual(["dist/esbuild.wasm"]); | ||
| 38 | }); | ||
| 39 | |||
| 40 | // `index.bundled.js` is the file consumers load — the package's own | ||
| 41 | // `dist/index.js` still has bare imports nothing can resolve inside the | ||
| 42 | // compiled binary. Staging index.js alone is not enough. | ||
| 43 | it("reports the packed entry when only the unbundled package entry is staged", () => { | ||
| 44 | expect( | ||
| 45 | missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/esbuild.wasm")), | ||
| 46 | ).toEqual(["dist/index.bundled.js"]); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("names files relative to the staged directory so errors are quotable", () => { | ||
| 50 | for (const relative of WORKER_BUNDLER_RUNTIME_FILES) { | ||
| 51 | expect(relative.startsWith("dist/")).toBe(true); | ||
| 52 | } | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("requires the packed entry and the wasm, not just the package entry", () => { | ||
| 56 | expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/index.bundled.js"); | ||
| 57 | expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/esbuild.wasm"); | ||
| 58 | }); | ||
| 59 | }); | ||
| @@ -0,0 +1,55 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // The packed `@cloudflare/worker-bundler` artifact that ships NEXT TO the | ||
| 3 | // compiled binary. | ||
| 4 | // | ||
| 5 | // `bun build --compile` cannot embed a package that is only ever addressed by | ||
| 6 | // bare specifier at runtime: bunfs has no `node_modules`, so an | ||
| 7 | // `import("@cloudflare/worker-bundler")` evaluated inside the binary resolves | ||
| 8 | // against the virtual root and throws | ||
| 9 | // ResolveMessage: Cannot find module '@cloudflare/worker-bundler' | ||
| 10 | // The build therefore copies the package's `dist/` next to the executable and | ||
| 11 | // `native-bindings.ts` publishes its absolute path as | ||
| 12 | // EXECUTOR_WORKER_BUNDLER_DIR. Consumers must load from that directory; a bare | ||
| 13 | // specifier is unresolvable in the packaged daemon by construction. | ||
| 14 | // | ||
| 15 | // This module is the single source of truth for which files that contract | ||
| 16 | // needs, so the build-time staging check and the runtime lookup cannot drift. | ||
| 17 | // They previously did: the build wrote `dist/index.bundled.js` (the file | ||
| 18 | // consumers actually load) while the runtime check only looked for | ||
| 19 | // `dist/index.js` and `dist/esbuild.wasm`. That drift fails OPEN — an | ||
| 20 | // incomplete staged copy passes the build, the runtime silently declines to | ||
| 21 | // publish the env var, and the consumer falls through to the bare specifier. | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | import { join } from "node:path"; | ||
| 25 | |||
| 26 | /** Directory name staged beside the executable, and looked up from execDir. */ | ||
| 27 | export const WORKER_BUNDLER_DIRNAME = "worker-bundler"; | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Files the colocated copy must contain to be usable. | ||
| 31 | * | ||
| 32 | * `index.bundled.js` is the entrypoint consumers load (the build packs it with | ||
| 33 | * esbuild so it has no bare-specifier imports of its own); `esbuild.wasm` is | ||
| 34 | * read at runtime beside it; `index.js` is the package's own entry, kept so the | ||
| 35 | * staged directory stays a faithful copy rather than a lookalike. | ||
| 36 | */ | ||
| 37 | export const WORKER_BUNDLER_RUNTIME_FILES = [ | ||
| 38 | "dist/index.js", | ||
| 39 | "dist/index.bundled.js", | ||
| 40 | "dist/esbuild.wasm", | ||
| 41 | ] as const; | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Which required files are absent from a staged worker-bundler directory. | ||
| 45 | * | ||
| 46 | * Takes the existence probe as a parameter so the build script, the runtime | ||
| 47 | * bootstrap, and tests all agree on the requirement without touching a real | ||
| 48 | * filesystem. Returns the POSIX-relative names, which is what error messages | ||
| 49 | * should quote. | ||
| 50 | */ | ||
| 51 | export const missingWorkerBundlerFiles = ( | ||
| 52 | dir: string, | ||
| 53 | exists: (path: string) => boolean, | ||
| 54 | ): readonly string[] => | ||
| 55 | WORKER_BUNDLER_RUNTIME_FILES.filter((relative) => !exists(join(dir, ...relative.split("/")))); | ||
| @@ -8,7 +8,7 @@ · 7 unmodified lines | |||
| 8 | "skipLibCheck": true, | 8 | "skipLibCheck": true, |
| 9 | "noEmit": true, | 9 | "noEmit": true, |
| 10 | "declaration": true, | 10 | "declaration": true, |
| 11 | "types": ["bun-types"] | 11 | "types": ["bun-types", "node"] |
| 12 | }, | 12 | }, |
| 13 | "include": ["src", "bin"] | 13 | "include": ["src", "bin"] |
| 14 | } | 14 | } |
| @@ -1,5 +1,340 @@ | |||
| 1 | # @executor-js/cloud | 1 | # @executor-js/cloud |
| 2 | 2 | ||
| 3 | ## 1.4.63 | ||
| 4 | |||
| 5 | ### Patch Changes | ||
| 6 | |||
| 7 | - Updated dependencies []: | ||
| 8 | - @executor-js/sdk@1.6.2 | ||
| 9 | - @executor-js/runtime-quickjs@1.6.2 | ||
| 10 | - @executor-js/execution@1.6.2 | ||
| 11 | - @executor-js/plugin-graphql@1.6.2 | ||
| 12 | - @executor-js/plugin-mcp@1.6.2 | ||
| 13 | - @executor-js/plugin-openapi@1.6.2 | ||
| 14 | - @executor-js/api@1.4.65 | ||
| 15 | - @executor-js/vite-plugin@0.0.62 | ||
| 16 | - @executor-js/cloudflare@0.0.44 | ||
| 17 | - @executor-js/host-mcp@1.4.4 | ||
| 18 | - @executor-js/mcp-apps-shell@1.4.13 | ||
| 19 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 20 | - @executor-js/plugin-toolkits@1.5.37 | ||
| 21 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 22 | - @executor-js/react@1.4.65 | ||
| 23 | |||
| 24 | ## 1.4.62 | ||
| 25 | |||
| 26 | ### Patch Changes | ||
| 27 | |||
| 28 | - Updated dependencies [[`9dff4e8`](https://github.com/UsefulSoftwareCo/executor/commit/9dff4e8e6598e7d3108634a71269245ba9b480bb), [`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: | ||
| 29 | - @executor-js/react@1.4.64 | ||
| 30 | - @executor-js/plugin-openapi@1.6.1 | ||
| 31 | - @executor-js/plugin-mcp@1.6.1 | ||
| 32 | - @executor-js/execution@1.6.1 | ||
| 33 | - @executor-js/sdk@1.6.1 | ||
| 34 | - @executor-js/api@1.4.64 | ||
| 35 | - @executor-js/mcp-apps-shell@1.4.12 | ||
| 36 | - @executor-js/plugin-graphql@1.6.1 | ||
| 37 | - @executor-js/plugin-toolkits@1.5.36 | ||
| 38 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 39 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 40 | - @executor-js/cloudflare@0.0.43 | ||
| 41 | - @executor-js/host-mcp@1.4.4 | ||
| 42 | - @executor-js/vite-plugin@0.0.61 | ||
| 43 | - @executor-js/runtime-quickjs@1.6.1 | ||
| 44 | |||
| 45 | ## 1.4.61 | ||
| 46 | |||
| 47 | ### Patch Changes | ||
| 48 | |||
| 49 | - 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)]: | ||
| 50 | - @executor-js/plugin-mcp@1.6.0 | ||
| 51 | - @executor-js/plugin-openapi@1.6.0 | ||
| 52 | - @executor-js/sdk@1.6.0 | ||
| 53 | - @executor-js/react@1.4.63 | ||
| 54 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 55 | - @executor-js/api@1.4.63 | ||
| 56 | - @executor-js/execution@1.6.0 | ||
| 57 | - @executor-js/vite-plugin@0.0.60 | ||
| 58 | - @executor-js/cloudflare@0.0.42 | ||
| 59 | - @executor-js/host-mcp@1.4.4 | ||
| 60 | - @executor-js/mcp-apps-shell@1.4.11 | ||
| 61 | - @executor-js/plugin-graphql@1.6.0 | ||
| 62 | - @executor-js/plugin-toolkits@1.5.35 | ||
| 63 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 64 | - @executor-js/runtime-quickjs@1.6.0 | ||
| 65 | |||
| 66 | ## 1.4.60 | ||
| 67 | |||
| 68 | ### Patch Changes | ||
| 69 | |||
| 70 | - 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)]: | ||
| 71 | - @executor-js/sdk@1.5.42 | ||
| 72 | - @executor-js/cloudflare@0.0.41 | ||
| 73 | - @executor-js/plugin-openapi@1.5.42 | ||
| 74 | - @executor-js/plugin-mcp@1.5.42 | ||
| 75 | - @executor-js/api@1.4.62 | ||
| 76 | - @executor-js/execution@1.5.42 | ||
| 77 | - @executor-js/vite-plugin@0.0.59 | ||
| 78 | - @executor-js/host-mcp@1.4.4 | ||
| 79 | - @executor-js/mcp-apps-shell@1.4.10 | ||
| 80 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 81 | - @executor-js/plugin-graphql@1.5.42 | ||
| 82 | - @executor-js/plugin-toolkits@1.5.34 | ||
| 83 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 84 | - @executor-js/react@1.4.62 | ||
| 85 | - @executor-js/runtime-quickjs@1.5.42 | ||
| 86 | |||
| 87 | ## 1.4.59 | ||
| 88 | |||
| 89 | ### Patch Changes | ||
| 90 | |||
| 91 | - Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: | ||
| 92 | - @executor-js/sdk@1.5.41 | ||
| 93 | - @executor-js/plugin-mcp@1.5.41 | ||
| 94 | - @executor-js/api@1.4.61 | ||
| 95 | - @executor-js/execution@1.5.41 | ||
| 96 | - @executor-js/vite-plugin@0.0.58 | ||
| 97 | - @executor-js/cloudflare@0.0.40 | ||
| 98 | - @executor-js/host-mcp@1.4.4 | ||
| 99 | - @executor-js/mcp-apps-shell@1.4.9 | ||
| 100 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 101 | - @executor-js/plugin-graphql@1.5.41 | ||
| 102 | - @executor-js/plugin-openapi@1.5.41 | ||
| 103 | - @executor-js/plugin-toolkits@1.5.33 | ||
| 104 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 105 | - @executor-js/react@1.4.61 | ||
| 106 | - @executor-js/runtime-quickjs@1.5.41 | ||
| 107 | |||
| 108 | ## 1.4.58 | ||
| 109 | |||
| 110 | ### Patch Changes | ||
| 111 | |||
| 112 | - Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: | ||
| 113 | - @executor-js/sdk@1.5.40 | ||
| 114 | - @executor-js/api@1.4.60 | ||
| 115 | - @executor-js/execution@1.5.40 | ||
| 116 | - @executor-js/vite-plugin@0.0.57 | ||
| 117 | - @executor-js/cloudflare@0.0.39 | ||
| 118 | - @executor-js/host-mcp@1.4.4 | ||
| 119 | - @executor-js/mcp-apps-shell@1.4.8 | ||
| 120 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 121 | - @executor-js/plugin-graphql@1.5.40 | ||
| 122 | - @executor-js/plugin-mcp@1.5.40 | ||
| 123 | - @executor-js/plugin-openapi@1.5.40 | ||
| 124 | - @executor-js/plugin-toolkits@1.5.32 | ||
| 125 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 126 | - @executor-js/react@1.4.60 | ||
| 127 | - @executor-js/runtime-quickjs@1.5.40 | ||
| 128 | |||
| 129 | ## 1.4.57 | ||
| 130 | |||
| 131 | ### Patch Changes | ||
| 132 | |||
| 133 | - Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: | ||
| 134 | - @executor-js/sdk@1.5.39 | ||
| 135 | - @executor-js/api@1.4.59 | ||
| 136 | - @executor-js/execution@1.5.39 | ||
| 137 | - @executor-js/vite-plugin@0.0.56 | ||
| 138 | - @executor-js/cloudflare@0.0.38 | ||
| 139 | - @executor-js/host-mcp@1.4.4 | ||
| 140 | - @executor-js/mcp-apps-shell@1.4.7 | ||
| 141 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 142 | - @executor-js/plugin-graphql@1.5.39 | ||
| 143 | - @executor-js/plugin-mcp@1.5.39 | ||
| 144 | - @executor-js/plugin-openapi@1.5.39 | ||
| 145 | - @executor-js/plugin-toolkits@1.5.31 | ||
| 146 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 147 | - @executor-js/react@1.4.59 | ||
| 148 | - @executor-js/runtime-quickjs@1.5.39 | ||
| 149 | |||
| 150 | ## 1.4.56 | ||
| 151 | |||
| 152 | ### Patch Changes | ||
| 153 | |||
| 154 | - 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)]: | ||
| 155 | - @executor-js/plugin-openapi@1.5.38 | ||
| 156 | - @executor-js/sdk@1.5.38 | ||
| 157 | - @executor-js/react@1.4.58 | ||
| 158 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 159 | - @executor-js/api@1.4.58 | ||
| 160 | - @executor-js/execution@1.5.38 | ||
| 161 | - @executor-js/vite-plugin@0.0.55 | ||
| 162 | - @executor-js/cloudflare@0.0.37 | ||
| 163 | - @executor-js/host-mcp@1.4.4 | ||
| 164 | - @executor-js/mcp-apps-shell@1.4.6 | ||
| 165 | - @executor-js/plugin-graphql@1.5.38 | ||
| 166 | - @executor-js/plugin-mcp@1.5.38 | ||
| 167 | - @executor-js/plugin-toolkits@1.5.30 | ||
| 168 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 169 | - @executor-js/runtime-quickjs@1.5.38 | ||
| 170 | |||
| 171 | ## 1.4.55 | ||
| 172 | |||
| 173 | ### Patch Changes | ||
| 174 | |||
| 175 | - Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: | ||
| 176 | - @executor-js/sdk@1.5.37 | ||
| 177 | - @executor-js/api@1.4.57 | ||
| 178 | - @executor-js/execution@1.5.37 | ||
| 179 | - @executor-js/vite-plugin@0.0.54 | ||
| 180 | - @executor-js/cloudflare@0.0.36 | ||
| 181 | - @executor-js/host-mcp@1.4.4 | ||
| 182 | - @executor-js/mcp-apps-shell@1.4.5 | ||
| 183 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 184 | - @executor-js/plugin-graphql@1.5.37 | ||
| 185 | - @executor-js/plugin-mcp@1.5.37 | ||
| 186 | - @executor-js/plugin-openapi@1.5.37 | ||
| 187 | - @executor-js/plugin-toolkits@1.5.29 | ||
| 188 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 189 | - @executor-js/react@1.4.57 | ||
| 190 | - @executor-js/runtime-quickjs@1.5.37 | ||
| 191 | |||
| 192 | ## 1.4.54 | ||
| 193 | |||
| 194 | ### Patch Changes | ||
| 195 | |||
| 196 | - Updated dependencies []: | ||
| 197 | - @executor-js/sdk@1.5.36 | ||
| 198 | - @executor-js/runtime-quickjs@1.5.36 | ||
| 199 | - @executor-js/execution@1.5.36 | ||
| 200 | - @executor-js/plugin-graphql@1.5.36 | ||
| 201 | - @executor-js/plugin-mcp@1.5.36 | ||
| 202 | - @executor-js/plugin-openapi@1.5.36 | ||
| 203 | - @executor-js/api@1.4.56 | ||
| 204 | - @executor-js/vite-plugin@0.0.53 | ||
| 205 | - @executor-js/cloudflare@0.0.35 | ||
| 206 | - @executor-js/host-mcp@1.4.4 | ||
| 207 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 208 | - @executor-js/plugin-toolkits@1.5.28 | ||
| 209 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 210 | - @executor-js/react@1.4.56 | ||
| 211 | |||
| 212 | ## 1.4.53 | ||
| 213 | |||
| 214 | ### Patch Changes | ||
| 215 | |||
| 216 | - 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)]: | ||
| 217 | - @executor-js/sdk@1.5.35 | ||
| 218 | - @executor-js/plugin-mcp@1.5.35 | ||
| 219 | - @executor-js/runtime-quickjs@1.5.35 | ||
| 220 | - @executor-js/api@1.4.55 | ||
| 221 | - @executor-js/execution@1.5.35 | ||
| 222 | - @executor-js/vite-plugin@0.0.52 | ||
| 223 | - @executor-js/cloudflare@0.0.34 | ||
| 224 | - @executor-js/host-mcp@1.4.4 | ||
| 225 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 226 | - @executor-js/plugin-apps@0.1.6 | ||
| 227 | - @executor-js/plugin-graphql@1.5.35 | ||
| 228 | - @executor-js/plugin-openapi@1.5.35 | ||
| 229 | - @executor-js/plugin-toolkits@1.5.27 | ||
| 230 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 231 | - @executor-js/react@1.4.55 | ||
| 232 | |||
| 233 | ## 1.4.52 | ||
| 234 | |||
| 235 | ### Patch Changes | ||
| 236 | |||
| 237 | - 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)]: | ||
| 238 | - @executor-js/sdk@1.5.34 | ||
| 239 | - @executor-js/execution@1.5.34 | ||
| 240 | - @executor-js/plugin-openapi@1.5.34 | ||
| 241 | - @executor-js/api@1.4.54 | ||
| 242 | - @executor-js/vite-plugin@0.0.51 | ||
| 243 | - @executor-js/cloudflare@0.0.33 | ||
| 244 | - @executor-js/host-mcp@1.4.4 | ||
| 245 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 246 | - @executor-js/plugin-apps@0.1.5 | ||
| 247 | - @executor-js/plugin-graphql@1.5.34 | ||
| 248 | - @executor-js/plugin-mcp@1.5.34 | ||
| 249 | - @executor-js/plugin-toolkits@1.5.26 | ||
| 250 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 251 | - @executor-js/react@1.4.54 | ||
| 252 | - @executor-js/runtime-quickjs@1.5.34 | ||
| 253 | |||
| 254 | ## 1.4.51 | ||
| 255 | |||
| 256 | ### Patch Changes | ||
| 257 | |||
| 258 | - Updated dependencies []: | ||
| 259 | - @executor-js/sdk@1.5.33 | ||
| 260 | - @executor-js/runtime-quickjs@1.5.33 | ||
| 261 | - @executor-js/execution@1.5.33 | ||
| 262 | - @executor-js/plugin-graphql@1.5.33 | ||
| 263 | - @executor-js/plugin-mcp@1.5.33 | ||
| 264 | - @executor-js/plugin-openapi@1.5.33 | ||
| 265 | - @executor-js/api@1.4.53 | ||
| 266 | - @executor-js/vite-plugin@0.0.50 | ||
| 267 | - @executor-js/cloudflare@0.0.32 | ||
| 268 | - @executor-js/host-mcp@1.4.4 | ||
| 269 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 270 | - @executor-js/plugin-apps@0.1.4 | ||
| 271 | - @executor-js/plugin-toolkits@1.5.25 | ||
| 272 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 273 | - @executor-js/react@1.4.53 | ||
| 274 | |||
| 275 | ## 1.4.50 | ||
| 276 | |||
| 277 | ### Patch Changes | ||
| 278 | |||
| 279 | - Updated dependencies []: | ||
| 280 | - @executor-js/sdk@1.5.32 | ||
| 281 | - @executor-js/runtime-quickjs@1.5.32 | ||
| 282 | - @executor-js/execution@1.5.32 | ||
| 283 | - @executor-js/plugin-graphql@1.5.32 | ||
| 284 | - @executor-js/plugin-mcp@1.5.32 | ||
| 285 | - @executor-js/plugin-openapi@1.5.32 | ||
| 286 | - @executor-js/api@1.4.52 | ||
| 287 | - @executor-js/vite-plugin@0.0.49 | ||
| 288 | - @executor-js/cloudflare@0.0.31 | ||
| 289 | - @executor-js/host-mcp@1.4.4 | ||
| 290 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 291 | - @executor-js/plugin-apps@0.1.3 | ||
| 292 | - @executor-js/plugin-toolkits@1.5.24 | ||
| 293 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 294 | - @executor-js/react@1.4.52 | ||
| 295 | |||
| 296 | ## 1.4.49 | ||
| 297 | |||
| 298 | ### Patch Changes | ||
| 299 | |||
| 300 | - Updated dependencies [[`9e38928`](https://github.com/UsefulSoftwareCo/executor/commit/9e38928f0fda9032b64b26990270c5d2b6690d13)]: | ||
| 301 | - @executor-js/plugin-openapi@1.5.31 | ||
| 302 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 303 | - @executor-js/sdk@1.5.31 | ||
| 304 | - @executor-js/runtime-quickjs@1.5.31 | ||
| 305 | - @executor-js/execution@1.5.31 | ||
| 306 | - @executor-js/plugin-graphql@1.5.31 | ||
| 307 | - @executor-js/plugin-mcp@1.5.31 | ||
| 308 | - @executor-js/api@1.4.51 | ||
| 309 | - @executor-js/vite-plugin@0.0.48 | ||
| 310 | - @executor-js/cloudflare@0.0.30 | ||
| 311 | - @executor-js/host-mcp@1.4.4 | ||
| 312 | - @executor-js/plugin-apps@0.1.2 | ||
| 313 | - @executor-js/plugin-toolkits@1.5.23 | ||
| 314 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 315 | - @executor-js/react@1.4.51 | ||
| 316 | |||
| 317 | ## 1.4.48 | ||
| 318 | |||
| 319 | ### Patch Changes | ||
| 320 | |||
| 321 | - Updated dependencies []: | ||
| 322 | - @executor-js/sdk@1.5.30 | ||
| 323 | - @executor-js/runtime-quickjs@1.5.30 | ||
| 324 | - @executor-js/execution@1.5.30 | ||
| 325 | - @executor-js/plugin-graphql@1.5.30 | ||
| 326 | - @executor-js/plugin-mcp@1.5.30 | ||
| 327 | - @executor-js/plugin-openapi@1.5.30 | ||
| 328 | - @executor-js/api@1.4.50 | ||
| 329 | - @executor-js/vite-plugin@0.0.47 | ||
| 330 | - @executor-js/cloudflare@0.0.29 | ||
| 331 | - @executor-js/host-mcp@1.4.4 | ||
| 332 | - @executor-js/runtime-dynamic-worker@1.4.4 | ||
| 333 | - @executor-js/plugin-apps@0.1.1 | ||
| 334 | - @executor-js/plugin-toolkits@1.5.22 | ||
| 335 | - @executor-js/plugin-workos-vault@0.0.2 | ||
| 336 | - @executor-js/react@1.4.50 | ||
| 337 | |||
| 3 | ## 1.4.47 | 338 | ## 1.4.47 |
| 4 | 339 | ||
| 5 | ### Patch Changes | 340 | ### Patch Changes |
| @@ -0,0 +1,23 @@ | |||
| 1 | -- Extraction used to persist an NDJSON operation's PER-LINE response schema | ||
| 2 | -- as the tool's output schema, while the invoke path returns an ARRAY of | ||
| 3 | -- parsed lines, so describe previews promised a single object invocations | ||
| 4 | -- never returned. The producer now wraps those schemas in an array. Persisted | ||
| 5 | -- `tool` rows can't be recognized as NDJSON-derived from their schema alone, | ||
| 6 | -- but the stored operation bindings kept the response content type, so the | ||
| 7 | -- affected connections are findable: stale-mark `tools_synced_at` and the | ||
| 8 | -- next read rebuilds their tool rows through the fixed producer. | ||
| 9 | -- | ||
| 10 | -- Effectively idempotent: re-running re-marks the same connections, and a | ||
| 11 | -- rebuild from unchanged bindings writes identical rows. | ||
| 12 | UPDATE "connection" c | ||
| 13 | SET "tools_synced_at" = NULL | ||
| 14 | FROM ( | ||
| 15 | SELECT DISTINCT ps."tenant", ps."data"::jsonb ->> 'integration' AS integration | ||
| 16 | FROM "plugin_storage" ps | ||
| 17 | WHERE ps."collection" = 'operation' | ||
| 18 | AND (ps."data"::text LIKE '%application/stream+json%' | ||
| 19 | OR ps."data"::text LIKE '%application/x-ndjson%' | ||
| 20 | OR ps."data"::text LIKE '%application/jsonl%') | ||
| 21 | ) ndjson | ||
| 22 | WHERE c."tenant" = ndjson."tenant" | ||
| 23 | AND c."integration" = ndjson."integration"; | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "oauth_client" ADD COLUMN "origin_redirect_uri" text; | ||
| @@ -0,0 +1,10 @@ | |||
| 1 | CREATE TABLE "subject" ( | ||
| 2 | "external_id" varchar(255) NOT NULL, | ||
| 3 | "created_at" timestamp NOT NULL, | ||
| 4 | "last_seen_at" bigint, | ||
| 5 | "status" text, | ||
| 6 | "row_id" varchar(255) PRIMARY KEY NOT NULL, | ||
| 7 | "tenant" varchar(255) NOT NULL | ||
| 8 | ); | ||
| 9 | --> statement-breakpoint | ||
| 10 | CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id"); | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | CREATE TABLE "artifact" ( | ||
| 2 | "id" varchar(255) NOT NULL, | ||
| 3 | "title" text NOT NULL, | ||
| 4 | "description" text, | ||
| 5 | "code" text NOT NULL, | ||
| 6 | "created_at" timestamp NOT NULL, | ||
| 7 | "updated_at" timestamp NOT NULL, | ||
| 8 | "row_id" varchar(255) PRIMARY KEY NOT NULL, | ||
| 9 | "tenant" varchar(255) NOT NULL, | ||
| 10 | "owner" varchar(255) NOT NULL, | ||
| 11 | "subject" varchar(255) NOT NULL | ||
| 12 | ); | ||
| 13 | --> statement-breakpoint | ||
| 14 | CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "artifact" ADD COLUMN "bindings" json; | ||
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "artifact" ADD COLUMN "preview" text; | ||
| @@ -0,0 +1,1290 @@ | |||
| 1 | { | ||
| 2 | "id": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6", | ||
| 3 | "prevId": "b07ddbda-be11-4679-9f00-16d1b3b5e879", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config_revised_at": { | ||
| 535 | "name": "config_revised_at", | ||
| 536 | "type": "bigint", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "config": { | ||
| 541 | "name": "config", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "health_check": { | ||
| 547 | "name": "health_check", | ||
| 548 | "type": "json", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "created_at": { | ||
| 686 | "name": "created_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "row_id": { | ||
| 692 | "name": "row_id", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": true, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "tenant": { | ||
| 698 | "name": "tenant", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": false, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "owner": { | ||
| 704 | "name": "owner", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "subject": { | ||
| 710 | "name": "subject", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | } | ||
| 715 | }, | ||
| 716 | "indexes": { | ||
| 717 | "oauth_client_uidx": { | ||
| 718 | "name": "oauth_client_uidx", | ||
| 719 | "columns": [ | ||
| 720 | { | ||
| 721 | "expression": "tenant", | ||
| 722 | "isExpression": false, | ||
| 723 | "asc": true, | ||
| 724 | "nulls": "last" | ||
| 725 | }, | ||
| 726 | { | ||
| 727 | "expression": "owner", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "subject", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "slug", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | } | ||
| 744 | ], | ||
| 745 | "isUnique": true, | ||
| 746 | "concurrently": false, | ||
| 747 | "method": "btree", | ||
| 748 | "with": {} | ||
| 749 | } | ||
| 750 | }, | ||
| 751 | "foreignKeys": {}, | ||
| 752 | "compositePrimaryKeys": {}, | ||
| 753 | "uniqueConstraints": {}, | ||
| 754 | "policies": {}, | ||
| 755 | "checkConstraints": {}, | ||
| 756 | "isRLSEnabled": false | ||
| 757 | }, | ||
| 758 | "public.oauth_session": { | ||
| 759 | "name": "oauth_session", | ||
| 760 | "schema": "", | ||
| 761 | "columns": { | ||
| 762 | "state": { | ||
| 763 | "name": "state", | ||
| 764 | "type": "varchar(255)", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_slug": { | ||
| 769 | "name": "client_slug", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "integration": { | ||
| 775 | "name": "integration", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "name": { | ||
| 781 | "name": "name", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "template": { | ||
| 787 | "name": "template", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "redirect_url": { | ||
| 793 | "name": "redirect_url", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "pkce_verifier": { | ||
| 799 | "name": "pkce_verifier", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "identity_label": { | ||
| 805 | "name": "identity_label", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "payload": { | ||
| 811 | "name": "payload", | ||
| 812 | "type": "json", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "expires_at": { | ||
| 817 | "name": "expires_at", | ||
| 818 | "type": "bigint", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "created_at": { | ||
| 823 | "name": "created_at", | ||
| 824 | "type": "timestamp", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "row_id": { | ||
| 829 | "name": "row_id", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": true, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "tenant": { | ||
| 835 | "name": "tenant", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": false, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "owner": { | ||
| 841 | "name": "owner", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "subject": { | ||
| 847 | "name": "subject", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | } | ||
| 852 | }, | ||
| 853 | "indexes": { | ||
| 854 | "oauth_session_uidx": { | ||
| 855 | "name": "oauth_session_uidx", | ||
| 856 | "columns": [ | ||
| 857 | { | ||
| 858 | "expression": "tenant", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | }, | ||
| 863 | { | ||
| 864 | "expression": "state", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | } | ||
| 869 | ], | ||
| 870 | "isUnique": true, | ||
| 871 | "concurrently": false, | ||
| 872 | "method": "btree", | ||
| 873 | "with": {} | ||
| 874 | } | ||
| 875 | }, | ||
| 876 | "foreignKeys": {}, | ||
| 877 | "compositePrimaryKeys": {}, | ||
| 878 | "uniqueConstraints": {}, | ||
| 879 | "policies": {}, | ||
| 880 | "checkConstraints": {}, | ||
| 881 | "isRLSEnabled": false | ||
| 882 | }, | ||
| 883 | "public.plugin_storage": { | ||
| 884 | "name": "plugin_storage", | ||
| 885 | "schema": "", | ||
| 886 | "columns": { | ||
| 887 | "plugin_id": { | ||
| 888 | "name": "plugin_id", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "collection": { | ||
| 894 | "name": "collection", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "key": { | ||
| 900 | "name": "key", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "data": { | ||
| 906 | "name": "data", | ||
| 907 | "type": "json", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "created_at": { | ||
| 912 | "name": "created_at", | ||
| 913 | "type": "timestamp", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "updated_at": { | ||
| 918 | "name": "updated_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "row_id": { | ||
| 924 | "name": "row_id", | ||
| 925 | "type": "varchar(255)", | ||
| 926 | "primaryKey": true, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "tenant": { | ||
| 930 | "name": "tenant", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "owner": { | ||
| 936 | "name": "owner", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "subject": { | ||
| 942 | "name": "subject", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | } | ||
| 947 | }, | ||
| 948 | "indexes": { | ||
| 949 | "plugin_storage_uidx": { | ||
| 950 | "name": "plugin_storage_uidx", | ||
| 951 | "columns": [ | ||
| 952 | { | ||
| 953 | "expression": "tenant", | ||
| 954 | "isExpression": false, | ||
| 955 | "asc": true, | ||
| 956 | "nulls": "last" | ||
| 957 | }, | ||
| 958 | { | ||
| 959 | "expression": "owner", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "subject", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "plugin_id", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "collection", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "key", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | } | ||
| 988 | ], | ||
| 989 | "isUnique": true, | ||
| 990 | "concurrently": false, | ||
| 991 | "method": "btree", | ||
| 992 | "with": {} | ||
| 993 | } | ||
| 994 | }, | ||
| 995 | "foreignKeys": {}, | ||
| 996 | "compositePrimaryKeys": {}, | ||
| 997 | "uniqueConstraints": {}, | ||
| 998 | "policies": {}, | ||
| 999 | "checkConstraints": {}, | ||
| 1000 | "isRLSEnabled": false | ||
| 1001 | }, | ||
| 1002 | "public.private_executor_cloud_settings": { | ||
| 1003 | "name": "private_executor_cloud_settings", | ||
| 1004 | "schema": "", | ||
| 1005 | "columns": { | ||
| 1006 | "id": { | ||
| 1007 | "name": "id", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": true, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "version": { | ||
| 1013 | "name": "version", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true, | ||
| 1017 | "default": "'1.0.0'" | ||
| 1018 | } | ||
| 1019 | }, | ||
| 1020 | "indexes": {}, | ||
| 1021 | "foreignKeys": {}, | ||
| 1022 | "compositePrimaryKeys": {}, | ||
| 1023 | "uniqueConstraints": {}, | ||
| 1024 | "policies": {}, | ||
| 1025 | "checkConstraints": {}, | ||
| 1026 | "isRLSEnabled": false | ||
| 1027 | }, | ||
| 1028 | "public.tool": { | ||
| 1029 | "name": "tool", | ||
| 1030 | "schema": "", | ||
| 1031 | "columns": { | ||
| 1032 | "integration": { | ||
| 1033 | "name": "integration", | ||
| 1034 | "type": "varchar(255)", | ||
| 1035 | "primaryKey": false, | ||
| 1036 | "notNull": true | ||
| 1037 | }, | ||
| 1038 | "connection": { | ||
| 1039 | "name": "connection", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "plugin_id": { | ||
| 1045 | "name": "plugin_id", | ||
| 1046 | "type": "text", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "name": { | ||
| 1051 | "name": "name", | ||
| 1052 | "type": "varchar(255)", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "description": { | ||
| 1057 | "name": "description", | ||
| 1058 | "type": "text", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "input_schema": { | ||
| 1063 | "name": "input_schema", | ||
| 1064 | "type": "json", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": false | ||
| 1067 | }, | ||
| 1068 | "output_schema": { | ||
| 1069 | "name": "output_schema", | ||
| 1070 | "type": "json", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": false | ||
| 1073 | }, | ||
| 1074 | "annotations": { | ||
| 1075 | "name": "annotations", | ||
| 1076 | "type": "json", | ||
| 1077 | "primaryKey": false, | ||
| 1078 | "notNull": false | ||
| 1079 | }, | ||
| 1080 | "created_at": { | ||
| 1081 | "name": "created_at", | ||
| 1082 | "type": "timestamp", | ||
| 1083 | "primaryKey": false, | ||
| 1084 | "notNull": true | ||
| 1085 | }, | ||
| 1086 | "updated_at": { | ||
| 1087 | "name": "updated_at", | ||
| 1088 | "type": "timestamp", | ||
| 1089 | "primaryKey": false, | ||
| 1090 | "notNull": true | ||
| 1091 | }, | ||
| 1092 | "row_id": { | ||
| 1093 | "name": "row_id", | ||
| 1094 | "type": "varchar(255)", | ||
| 1095 | "primaryKey": true, | ||
| 1096 | "notNull": true | ||
| 1097 | }, | ||
| 1098 | "tenant": { | ||
| 1099 | "name": "tenant", | ||
| 1100 | "type": "varchar(255)", | ||
| 1101 | "primaryKey": false, | ||
| 1102 | "notNull": true | ||
| 1103 | }, | ||
| 1104 | "owner": { | ||
| 1105 | "name": "owner", | ||
| 1106 | "type": "varchar(255)", | ||
| 1107 | "primaryKey": false, | ||
| 1108 | "notNull": true | ||
| 1109 | }, | ||
| 1110 | "subject": { | ||
| 1111 | "name": "subject", | ||
| 1112 | "type": "varchar(255)", | ||
| 1113 | "primaryKey": false, | ||
| 1114 | "notNull": true | ||
| 1115 | } | ||
| 1116 | }, | ||
| 1117 | "indexes": { | ||
| 1118 | "tool_uidx": { | ||
| 1119 | "name": "tool_uidx", | ||
| 1120 | "columns": [ | ||
| 1121 | { | ||
| 1122 | "expression": "tenant", | ||
| 1123 | "isExpression": false, | ||
| 1124 | "asc": true, | ||
| 1125 | "nulls": "last" | ||
| 1126 | }, | ||
| 1127 | { | ||
| 1128 | "expression": "owner", | ||
| 1129 | "isExpression": false, | ||
| 1130 | "asc": true, | ||
| 1131 | "nulls": "last" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "expression": "subject", | ||
| 1135 | "isExpression": false, | ||
| 1136 | "asc": true, | ||
| 1137 | "nulls": "last" | ||
| 1138 | }, | ||
| 1139 | { | ||
| 1140 | "expression": "integration", | ||
| 1141 | "isExpression": false, | ||
| 1142 | "asc": true, | ||
| 1143 | "nulls": "last" | ||
| 1144 | }, | ||
| 1145 | { | ||
| 1146 | "expression": "connection", | ||
| 1147 | "isExpression": false, | ||
| 1148 | "asc": true, | ||
| 1149 | "nulls": "last" | ||
| 1150 | }, | ||
| 1151 | { | ||
| 1152 | "expression": "name", | ||
| 1153 | "isExpression": false, | ||
| 1154 | "asc": true, | ||
| 1155 | "nulls": "last" | ||
| 1156 | } | ||
| 1157 | ], | ||
| 1158 | "isUnique": true, | ||
| 1159 | "concurrently": false, | ||
| 1160 | "method": "btree", | ||
| 1161 | "with": {} | ||
| 1162 | } | ||
| 1163 | }, | ||
| 1164 | "foreignKeys": {}, | ||
| 1165 | "compositePrimaryKeys": {}, | ||
| 1166 | "uniqueConstraints": {}, | ||
| 1167 | "policies": {}, | ||
| 1168 | "checkConstraints": {}, | ||
| 1169 | "isRLSEnabled": false | ||
| 1170 | }, | ||
| 1171 | "public.tool_policy": { | ||
| 1172 | "name": "tool_policy", | ||
| 1173 | "schema": "", | ||
| 1174 | "columns": { | ||
| 1175 | "id": { | ||
| 1176 | "name": "id", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "pattern": { | ||
| 1182 | "name": "pattern", | ||
| 1183 | "type": "text", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "action": { | ||
| 1188 | "name": "action", | ||
| 1189 | "type": "text", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | }, | ||
| 1193 | "position": { | ||
| 1194 | "name": "position", | ||
| 1195 | "type": "text", | ||
| 1196 | "primaryKey": false, | ||
| 1197 | "notNull": true | ||
| 1198 | }, | ||
| 1199 | "created_at": { | ||
| 1200 | "name": "created_at", | ||
| 1201 | "type": "timestamp", | ||
| 1202 | "primaryKey": false, | ||
| 1203 | "notNull": true | ||
| 1204 | }, | ||
| 1205 | "updated_at": { | ||
| 1206 | "name": "updated_at", | ||
| 1207 | "type": "timestamp", | ||
| 1208 | "primaryKey": false, | ||
| 1209 | "notNull": true | ||
| 1210 | }, | ||
| 1211 | "row_id": { | ||
| 1212 | "name": "row_id", | ||
| 1213 | "type": "varchar(255)", | ||
| 1214 | "primaryKey": true, | ||
| 1215 | "notNull": true | ||
| 1216 | }, | ||
| 1217 | "tenant": { | ||
| 1218 | "name": "tenant", | ||
| 1219 | "type": "varchar(255)", | ||
| 1220 | "primaryKey": false, | ||
| 1221 | "notNull": true | ||
| 1222 | }, | ||
| 1223 | "owner": { | ||
| 1224 | "name": "owner", | ||
| 1225 | "type": "varchar(255)", | ||
| 1226 | "primaryKey": false, | ||
| 1227 | "notNull": true | ||
| 1228 | }, | ||
| 1229 | "subject": { | ||
| 1230 | "name": "subject", | ||
| 1231 | "type": "varchar(255)", | ||
| 1232 | "primaryKey": false, | ||
| 1233 | "notNull": true | ||
| 1234 | } | ||
| 1235 | }, | ||
| 1236 | "indexes": { | ||
| 1237 | "tool_policy_uidx": { | ||
| 1238 | "name": "tool_policy_uidx", | ||
| 1239 | "columns": [ | ||
| 1240 | { | ||
| 1241 | "expression": "tenant", | ||
| 1242 | "isExpression": false, | ||
| 1243 | "asc": true, | ||
| 1244 | "nulls": "last" | ||
| 1245 | }, | ||
| 1246 | { | ||
| 1247 | "expression": "owner", | ||
| 1248 | "isExpression": false, | ||
| 1249 | "asc": true, | ||
| 1250 | "nulls": "last" | ||
| 1251 | }, | ||
| 1252 | { | ||
| 1253 | "expression": "subject", | ||
| 1254 | "isExpression": false, | ||
| 1255 | "asc": true, | ||
| 1256 | "nulls": "last" | ||
| 1257 | }, | ||
| 1258 | { | ||
| 1259 | "expression": "id", | ||
| 1260 | "isExpression": false, | ||
| 1261 | "asc": true, | ||
| 1262 | "nulls": "last" | ||
| 1263 | } | ||
| 1264 | ], | ||
| 1265 | "isUnique": true, | ||
| 1266 | "concurrently": false, | ||
| 1267 | "method": "btree", | ||
| 1268 | "with": {} | ||
| 1269 | } | ||
| 1270 | }, | ||
| 1271 | "foreignKeys": {}, | ||
| 1272 | "compositePrimaryKeys": {}, | ||
| 1273 | "uniqueConstraints": {}, | ||
| 1274 | "policies": {}, | ||
| 1275 | "checkConstraints": {}, | ||
| 1276 | "isRLSEnabled": false | ||
| 1277 | } | ||
| 1278 | }, | ||
| 1279 | "enums": {}, | ||
| 1280 | "schemas": {}, | ||
| 1281 | "sequences": {}, | ||
| 1282 | "roles": {}, | ||
| 1283 | "policies": {}, | ||
| 1284 | "views": {}, | ||
| 1285 | "_meta": { | ||
| 1286 | "columns": {}, | ||
| 1287 | "schemas": {}, | ||
| 1288 | "tables": {} | ||
| 1289 | } | ||
| 1290 | } | ||
| @@ -0,0 +1,1296 @@ | |||
| 1 | { | ||
| 2 | "id": "e285be7d-2427-4b1b-8b86-50a80ada25af", | ||
| 3 | "prevId": "c27a8958-ad73-4590-9b9b-d1b2584e0bc6", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config": { | ||
| 535 | "name": "config", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "health_check": { | ||
| 541 | "name": "health_check", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "config_revised_at": { | ||
| 547 | "name": "config_revised_at", | ||
| 548 | "type": "bigint", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "origin_redirect_uri": { | ||
| 686 | "name": "origin_redirect_uri", | ||
| 687 | "type": "text", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": false | ||
| 690 | }, | ||
| 691 | "created_at": { | ||
| 692 | "name": "created_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "owner": { | ||
| 710 | "name": "owner", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | }, | ||
| 715 | "subject": { | ||
| 716 | "name": "subject", | ||
| 717 | "type": "varchar(255)", | ||
| 718 | "primaryKey": false, | ||
| 719 | "notNull": true | ||
| 720 | } | ||
| 721 | }, | ||
| 722 | "indexes": { | ||
| 723 | "oauth_client_uidx": { | ||
| 724 | "name": "oauth_client_uidx", | ||
| 725 | "columns": [ | ||
| 726 | { | ||
| 727 | "expression": "tenant", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "owner", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "subject", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "expression": "slug", | ||
| 746 | "isExpression": false, | ||
| 747 | "asc": true, | ||
| 748 | "nulls": "last" | ||
| 749 | } | ||
| 750 | ], | ||
| 751 | "isUnique": true, | ||
| 752 | "concurrently": false, | ||
| 753 | "method": "btree", | ||
| 754 | "with": {} | ||
| 755 | } | ||
| 756 | }, | ||
| 757 | "foreignKeys": {}, | ||
| 758 | "compositePrimaryKeys": {}, | ||
| 759 | "uniqueConstraints": {}, | ||
| 760 | "policies": {}, | ||
| 761 | "checkConstraints": {}, | ||
| 762 | "isRLSEnabled": false | ||
| 763 | }, | ||
| 764 | "public.oauth_session": { | ||
| 765 | "name": "oauth_session", | ||
| 766 | "schema": "", | ||
| 767 | "columns": { | ||
| 768 | "state": { | ||
| 769 | "name": "state", | ||
| 770 | "type": "varchar(255)", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_slug": { | ||
| 775 | "name": "client_slug", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "integration": { | ||
| 781 | "name": "integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "name": { | ||
| 787 | "name": "name", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "template": { | ||
| 793 | "name": "template", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "redirect_url": { | ||
| 799 | "name": "redirect_url", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "pkce_verifier": { | ||
| 805 | "name": "pkce_verifier", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "identity_label": { | ||
| 811 | "name": "identity_label", | ||
| 812 | "type": "text", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": false | ||
| 815 | }, | ||
| 816 | "payload": { | ||
| 817 | "name": "payload", | ||
| 818 | "type": "json", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "expires_at": { | ||
| 823 | "name": "expires_at", | ||
| 824 | "type": "bigint", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "created_at": { | ||
| 829 | "name": "created_at", | ||
| 830 | "type": "timestamp", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "row_id": { | ||
| 835 | "name": "row_id", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": true, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "tenant": { | ||
| 841 | "name": "tenant", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "owner": { | ||
| 847 | "name": "owner", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | }, | ||
| 852 | "subject": { | ||
| 853 | "name": "subject", | ||
| 854 | "type": "varchar(255)", | ||
| 855 | "primaryKey": false, | ||
| 856 | "notNull": true | ||
| 857 | } | ||
| 858 | }, | ||
| 859 | "indexes": { | ||
| 860 | "oauth_session_uidx": { | ||
| 861 | "name": "oauth_session_uidx", | ||
| 862 | "columns": [ | ||
| 863 | { | ||
| 864 | "expression": "tenant", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | }, | ||
| 869 | { | ||
| 870 | "expression": "state", | ||
| 871 | "isExpression": false, | ||
| 872 | "asc": true, | ||
| 873 | "nulls": "last" | ||
| 874 | } | ||
| 875 | ], | ||
| 876 | "isUnique": true, | ||
| 877 | "concurrently": false, | ||
| 878 | "method": "btree", | ||
| 879 | "with": {} | ||
| 880 | } | ||
| 881 | }, | ||
| 882 | "foreignKeys": {}, | ||
| 883 | "compositePrimaryKeys": {}, | ||
| 884 | "uniqueConstraints": {}, | ||
| 885 | "policies": {}, | ||
| 886 | "checkConstraints": {}, | ||
| 887 | "isRLSEnabled": false | ||
| 888 | }, | ||
| 889 | "public.plugin_storage": { | ||
| 890 | "name": "plugin_storage", | ||
| 891 | "schema": "", | ||
| 892 | "columns": { | ||
| 893 | "plugin_id": { | ||
| 894 | "name": "plugin_id", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "collection": { | ||
| 900 | "name": "collection", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "key": { | ||
| 906 | "name": "key", | ||
| 907 | "type": "varchar(255)", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "data": { | ||
| 912 | "name": "data", | ||
| 913 | "type": "json", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "created_at": { | ||
| 918 | "name": "created_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "updated_at": { | ||
| 924 | "name": "updated_at", | ||
| 925 | "type": "timestamp", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "row_id": { | ||
| 930 | "name": "row_id", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": true, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "tenant": { | ||
| 936 | "name": "tenant", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "owner": { | ||
| 942 | "name": "owner", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "subject": { | ||
| 948 | "name": "subject", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | } | ||
| 953 | }, | ||
| 954 | "indexes": { | ||
| 955 | "plugin_storage_uidx": { | ||
| 956 | "name": "plugin_storage_uidx", | ||
| 957 | "columns": [ | ||
| 958 | { | ||
| 959 | "expression": "tenant", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "owner", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "subject", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "plugin_id", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "collection", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "key", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.private_executor_cloud_settings": { | ||
| 1009 | "name": "private_executor_cloud_settings", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "id": { | ||
| 1013 | "name": "id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": true, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "version": { | ||
| 1019 | "name": "version", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true, | ||
| 1023 | "default": "'1.0.0'" | ||
| 1024 | } | ||
| 1025 | }, | ||
| 1026 | "indexes": {}, | ||
| 1027 | "foreignKeys": {}, | ||
| 1028 | "compositePrimaryKeys": {}, | ||
| 1029 | "uniqueConstraints": {}, | ||
| 1030 | "policies": {}, | ||
| 1031 | "checkConstraints": {}, | ||
| 1032 | "isRLSEnabled": false | ||
| 1033 | }, | ||
| 1034 | "public.tool": { | ||
| 1035 | "name": "tool", | ||
| 1036 | "schema": "", | ||
| 1037 | "columns": { | ||
| 1038 | "integration": { | ||
| 1039 | "name": "integration", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "connection": { | ||
| 1045 | "name": "connection", | ||
| 1046 | "type": "varchar(255)", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "plugin_id": { | ||
| 1051 | "name": "plugin_id", | ||
| 1052 | "type": "text", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "name": { | ||
| 1057 | "name": "name", | ||
| 1058 | "type": "varchar(255)", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "description": { | ||
| 1063 | "name": "description", | ||
| 1064 | "type": "text", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": true | ||
| 1067 | }, | ||
| 1068 | "input_schema": { | ||
| 1069 | "name": "input_schema", | ||
| 1070 | "type": "json", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": false | ||
| 1073 | }, | ||
| 1074 | "output_schema": { | ||
| 1075 | "name": "output_schema", | ||
| 1076 | "type": "json", | ||
| 1077 | "primaryKey": false, | ||
| 1078 | "notNull": false | ||
| 1079 | }, | ||
| 1080 | "annotations": { | ||
| 1081 | "name": "annotations", | ||
| 1082 | "type": "json", | ||
| 1083 | "primaryKey": false, | ||
| 1084 | "notNull": false | ||
| 1085 | }, | ||
| 1086 | "created_at": { | ||
| 1087 | "name": "created_at", | ||
| 1088 | "type": "timestamp", | ||
| 1089 | "primaryKey": false, | ||
| 1090 | "notNull": true | ||
| 1091 | }, | ||
| 1092 | "updated_at": { | ||
| 1093 | "name": "updated_at", | ||
| 1094 | "type": "timestamp", | ||
| 1095 | "primaryKey": false, | ||
| 1096 | "notNull": true | ||
| 1097 | }, | ||
| 1098 | "row_id": { | ||
| 1099 | "name": "row_id", | ||
| 1100 | "type": "varchar(255)", | ||
| 1101 | "primaryKey": true, | ||
| 1102 | "notNull": true | ||
| 1103 | }, | ||
| 1104 | "tenant": { | ||
| 1105 | "name": "tenant", | ||
| 1106 | "type": "varchar(255)", | ||
| 1107 | "primaryKey": false, | ||
| 1108 | "notNull": true | ||
| 1109 | }, | ||
| 1110 | "owner": { | ||
| 1111 | "name": "owner", | ||
| 1112 | "type": "varchar(255)", | ||
| 1113 | "primaryKey": false, | ||
| 1114 | "notNull": true | ||
| 1115 | }, | ||
| 1116 | "subject": { | ||
| 1117 | "name": "subject", | ||
| 1118 | "type": "varchar(255)", | ||
| 1119 | "primaryKey": false, | ||
| 1120 | "notNull": true | ||
| 1121 | } | ||
| 1122 | }, | ||
| 1123 | "indexes": { | ||
| 1124 | "tool_uidx": { | ||
| 1125 | "name": "tool_uidx", | ||
| 1126 | "columns": [ | ||
| 1127 | { | ||
| 1128 | "expression": "tenant", | ||
| 1129 | "isExpression": false, | ||
| 1130 | "asc": true, | ||
| 1131 | "nulls": "last" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "expression": "owner", | ||
| 1135 | "isExpression": false, | ||
| 1136 | "asc": true, | ||
| 1137 | "nulls": "last" | ||
| 1138 | }, | ||
| 1139 | { | ||
| 1140 | "expression": "subject", | ||
| 1141 | "isExpression": false, | ||
| 1142 | "asc": true, | ||
| 1143 | "nulls": "last" | ||
| 1144 | }, | ||
| 1145 | { | ||
| 1146 | "expression": "integration", | ||
| 1147 | "isExpression": false, | ||
| 1148 | "asc": true, | ||
| 1149 | "nulls": "last" | ||
| 1150 | }, | ||
| 1151 | { | ||
| 1152 | "expression": "connection", | ||
| 1153 | "isExpression": false, | ||
| 1154 | "asc": true, | ||
| 1155 | "nulls": "last" | ||
| 1156 | }, | ||
| 1157 | { | ||
| 1158 | "expression": "name", | ||
| 1159 | "isExpression": false, | ||
| 1160 | "asc": true, | ||
| 1161 | "nulls": "last" | ||
| 1162 | } | ||
| 1163 | ], | ||
| 1164 | "isUnique": true, | ||
| 1165 | "concurrently": false, | ||
| 1166 | "method": "btree", | ||
| 1167 | "with": {} | ||
| 1168 | } | ||
| 1169 | }, | ||
| 1170 | "foreignKeys": {}, | ||
| 1171 | "compositePrimaryKeys": {}, | ||
| 1172 | "uniqueConstraints": {}, | ||
| 1173 | "policies": {}, | ||
| 1174 | "checkConstraints": {}, | ||
| 1175 | "isRLSEnabled": false | ||
| 1176 | }, | ||
| 1177 | "public.tool_policy": { | ||
| 1178 | "name": "tool_policy", | ||
| 1179 | "schema": "", | ||
| 1180 | "columns": { | ||
| 1181 | "id": { | ||
| 1182 | "name": "id", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "pattern": { | ||
| 1188 | "name": "pattern", | ||
| 1189 | "type": "text", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | }, | ||
| 1193 | "action": { | ||
| 1194 | "name": "action", | ||
| 1195 | "type": "text", | ||
| 1196 | "primaryKey": false, | ||
| 1197 | "notNull": true | ||
| 1198 | }, | ||
| 1199 | "position": { | ||
| 1200 | "name": "position", | ||
| 1201 | "type": "text", | ||
| 1202 | "primaryKey": false, | ||
| 1203 | "notNull": true | ||
| 1204 | }, | ||
| 1205 | "created_at": { | ||
| 1206 | "name": "created_at", | ||
| 1207 | "type": "timestamp", | ||
| 1208 | "primaryKey": false, | ||
| 1209 | "notNull": true | ||
| 1210 | }, | ||
| 1211 | "updated_at": { | ||
| 1212 | "name": "updated_at", | ||
| 1213 | "type": "timestamp", | ||
| 1214 | "primaryKey": false, | ||
| 1215 | "notNull": true | ||
| 1216 | }, | ||
| 1217 | "row_id": { | ||
| 1218 | "name": "row_id", | ||
| 1219 | "type": "varchar(255)", | ||
| 1220 | "primaryKey": true, | ||
| 1221 | "notNull": true | ||
| 1222 | }, | ||
| 1223 | "tenant": { | ||
| 1224 | "name": "tenant", | ||
| 1225 | "type": "varchar(255)", | ||
| 1226 | "primaryKey": false, | ||
| 1227 | "notNull": true | ||
| 1228 | }, | ||
| 1229 | "owner": { | ||
| 1230 | "name": "owner", | ||
| 1231 | "type": "varchar(255)", | ||
| 1232 | "primaryKey": false, | ||
| 1233 | "notNull": true | ||
| 1234 | }, | ||
| 1235 | "subject": { | ||
| 1236 | "name": "subject", | ||
| 1237 | "type": "varchar(255)", | ||
| 1238 | "primaryKey": false, | ||
| 1239 | "notNull": true | ||
| 1240 | } | ||
| 1241 | }, | ||
| 1242 | "indexes": { | ||
| 1243 | "tool_policy_uidx": { | ||
| 1244 | "name": "tool_policy_uidx", | ||
| 1245 | "columns": [ | ||
| 1246 | { | ||
| 1247 | "expression": "tenant", | ||
| 1248 | "isExpression": false, | ||
| 1249 | "asc": true, | ||
| 1250 | "nulls": "last" | ||
| 1251 | }, | ||
| 1252 | { | ||
| 1253 | "expression": "owner", | ||
| 1254 | "isExpression": false, | ||
| 1255 | "asc": true, | ||
| 1256 | "nulls": "last" | ||
| 1257 | }, | ||
| 1258 | { | ||
| 1259 | "expression": "subject", | ||
| 1260 | "isExpression": false, | ||
| 1261 | "asc": true, | ||
| 1262 | "nulls": "last" | ||
| 1263 | }, | ||
| 1264 | { | ||
| 1265 | "expression": "id", | ||
| 1266 | "isExpression": false, | ||
| 1267 | "asc": true, | ||
| 1268 | "nulls": "last" | ||
| 1269 | } | ||
| 1270 | ], | ||
| 1271 | "isUnique": true, | ||
| 1272 | "concurrently": false, | ||
| 1273 | "method": "btree", | ||
| 1274 | "with": {} | ||
| 1275 | } | ||
| 1276 | }, | ||
| 1277 | "foreignKeys": {}, | ||
| 1278 | "compositePrimaryKeys": {}, | ||
| 1279 | "uniqueConstraints": {}, | ||
| 1280 | "policies": {}, | ||
| 1281 | "checkConstraints": {}, | ||
| 1282 | "isRLSEnabled": false | ||
| 1283 | } | ||
| 1284 | }, | ||
| 1285 | "enums": {}, | ||
| 1286 | "schemas": {}, | ||
| 1287 | "sequences": {}, | ||
| 1288 | "roles": {}, | ||
| 1289 | "policies": {}, | ||
| 1290 | "views": {}, | ||
| 1291 | "_meta": { | ||
| 1292 | "columns": {}, | ||
| 1293 | "schemas": {}, | ||
| 1294 | "tables": {} | ||
| 1295 | } | ||
| 1296 | } | ||
| @@ -0,0 +1,1367 @@ | |||
| 1 | { | ||
| 2 | "id": "97114994-f0fc-4419-80bf-21c529a6cf0b", | ||
| 3 | "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config": { | ||
| 535 | "name": "config", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "health_check": { | ||
| 541 | "name": "health_check", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "config_revised_at": { | ||
| 547 | "name": "config_revised_at", | ||
| 548 | "type": "bigint", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "origin_redirect_uri": { | ||
| 686 | "name": "origin_redirect_uri", | ||
| 687 | "type": "text", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": false | ||
| 690 | }, | ||
| 691 | "created_at": { | ||
| 692 | "name": "created_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "owner": { | ||
| 710 | "name": "owner", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | }, | ||
| 715 | "subject": { | ||
| 716 | "name": "subject", | ||
| 717 | "type": "varchar(255)", | ||
| 718 | "primaryKey": false, | ||
| 719 | "notNull": true | ||
| 720 | } | ||
| 721 | }, | ||
| 722 | "indexes": { | ||
| 723 | "oauth_client_uidx": { | ||
| 724 | "name": "oauth_client_uidx", | ||
| 725 | "columns": [ | ||
| 726 | { | ||
| 727 | "expression": "tenant", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "owner", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "subject", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "expression": "slug", | ||
| 746 | "isExpression": false, | ||
| 747 | "asc": true, | ||
| 748 | "nulls": "last" | ||
| 749 | } | ||
| 750 | ], | ||
| 751 | "isUnique": true, | ||
| 752 | "concurrently": false, | ||
| 753 | "method": "btree", | ||
| 754 | "with": {} | ||
| 755 | } | ||
| 756 | }, | ||
| 757 | "foreignKeys": {}, | ||
| 758 | "compositePrimaryKeys": {}, | ||
| 759 | "uniqueConstraints": {}, | ||
| 760 | "policies": {}, | ||
| 761 | "checkConstraints": {}, | ||
| 762 | "isRLSEnabled": false | ||
| 763 | }, | ||
| 764 | "public.oauth_session": { | ||
| 765 | "name": "oauth_session", | ||
| 766 | "schema": "", | ||
| 767 | "columns": { | ||
| 768 | "state": { | ||
| 769 | "name": "state", | ||
| 770 | "type": "varchar(255)", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_slug": { | ||
| 775 | "name": "client_slug", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "integration": { | ||
| 781 | "name": "integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "name": { | ||
| 787 | "name": "name", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "template": { | ||
| 793 | "name": "template", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "redirect_url": { | ||
| 799 | "name": "redirect_url", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "pkce_verifier": { | ||
| 805 | "name": "pkce_verifier", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "identity_label": { | ||
| 811 | "name": "identity_label", | ||
| 812 | "type": "text", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": false | ||
| 815 | }, | ||
| 816 | "payload": { | ||
| 817 | "name": "payload", | ||
| 818 | "type": "json", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "expires_at": { | ||
| 823 | "name": "expires_at", | ||
| 824 | "type": "bigint", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "created_at": { | ||
| 829 | "name": "created_at", | ||
| 830 | "type": "timestamp", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "row_id": { | ||
| 835 | "name": "row_id", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": true, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "tenant": { | ||
| 841 | "name": "tenant", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "owner": { | ||
| 847 | "name": "owner", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | }, | ||
| 852 | "subject": { | ||
| 853 | "name": "subject", | ||
| 854 | "type": "varchar(255)", | ||
| 855 | "primaryKey": false, | ||
| 856 | "notNull": true | ||
| 857 | } | ||
| 858 | }, | ||
| 859 | "indexes": { | ||
| 860 | "oauth_session_uidx": { | ||
| 861 | "name": "oauth_session_uidx", | ||
| 862 | "columns": [ | ||
| 863 | { | ||
| 864 | "expression": "tenant", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | }, | ||
| 869 | { | ||
| 870 | "expression": "state", | ||
| 871 | "isExpression": false, | ||
| 872 | "asc": true, | ||
| 873 | "nulls": "last" | ||
| 874 | } | ||
| 875 | ], | ||
| 876 | "isUnique": true, | ||
| 877 | "concurrently": false, | ||
| 878 | "method": "btree", | ||
| 879 | "with": {} | ||
| 880 | } | ||
| 881 | }, | ||
| 882 | "foreignKeys": {}, | ||
| 883 | "compositePrimaryKeys": {}, | ||
| 884 | "uniqueConstraints": {}, | ||
| 885 | "policies": {}, | ||
| 886 | "checkConstraints": {}, | ||
| 887 | "isRLSEnabled": false | ||
| 888 | }, | ||
| 889 | "public.plugin_storage": { | ||
| 890 | "name": "plugin_storage", | ||
| 891 | "schema": "", | ||
| 892 | "columns": { | ||
| 893 | "plugin_id": { | ||
| 894 | "name": "plugin_id", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "collection": { | ||
| 900 | "name": "collection", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "key": { | ||
| 906 | "name": "key", | ||
| 907 | "type": "varchar(255)", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "data": { | ||
| 912 | "name": "data", | ||
| 913 | "type": "json", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "created_at": { | ||
| 918 | "name": "created_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "updated_at": { | ||
| 924 | "name": "updated_at", | ||
| 925 | "type": "timestamp", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "row_id": { | ||
| 930 | "name": "row_id", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": true, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "tenant": { | ||
| 936 | "name": "tenant", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "owner": { | ||
| 942 | "name": "owner", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "subject": { | ||
| 948 | "name": "subject", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | } | ||
| 953 | }, | ||
| 954 | "indexes": { | ||
| 955 | "plugin_storage_uidx": { | ||
| 956 | "name": "plugin_storage_uidx", | ||
| 957 | "columns": [ | ||
| 958 | { | ||
| 959 | "expression": "tenant", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "owner", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "subject", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "plugin_id", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "collection", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "key", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.private_executor_cloud_settings": { | ||
| 1009 | "name": "private_executor_cloud_settings", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "id": { | ||
| 1013 | "name": "id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": true, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "version": { | ||
| 1019 | "name": "version", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true, | ||
| 1023 | "default": "'1.0.0'" | ||
| 1024 | } | ||
| 1025 | }, | ||
| 1026 | "indexes": {}, | ||
| 1027 | "foreignKeys": {}, | ||
| 1028 | "compositePrimaryKeys": {}, | ||
| 1029 | "uniqueConstraints": {}, | ||
| 1030 | "policies": {}, | ||
| 1031 | "checkConstraints": {}, | ||
| 1032 | "isRLSEnabled": false | ||
| 1033 | }, | ||
| 1034 | "public.subject": { | ||
| 1035 | "name": "subject", | ||
| 1036 | "schema": "", | ||
| 1037 | "columns": { | ||
| 1038 | "external_id": { | ||
| 1039 | "name": "external_id", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "created_at": { | ||
| 1045 | "name": "created_at", | ||
| 1046 | "type": "timestamp", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "last_seen_at": { | ||
| 1051 | "name": "last_seen_at", | ||
| 1052 | "type": "bigint", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": false | ||
| 1055 | }, | ||
| 1056 | "status": { | ||
| 1057 | "name": "status", | ||
| 1058 | "type": "text", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": false | ||
| 1061 | }, | ||
| 1062 | "row_id": { | ||
| 1063 | "name": "row_id", | ||
| 1064 | "type": "varchar(255)", | ||
| 1065 | "primaryKey": true, | ||
| 1066 | "notNull": true | ||
| 1067 | }, | ||
| 1068 | "tenant": { | ||
| 1069 | "name": "tenant", | ||
| 1070 | "type": "varchar(255)", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": true | ||
| 1073 | } | ||
| 1074 | }, | ||
| 1075 | "indexes": { | ||
| 1076 | "subject_uidx": { | ||
| 1077 | "name": "subject_uidx", | ||
| 1078 | "columns": [ | ||
| 1079 | { | ||
| 1080 | "expression": "tenant", | ||
| 1081 | "isExpression": false, | ||
| 1082 | "asc": true, | ||
| 1083 | "nulls": "last" | ||
| 1084 | }, | ||
| 1085 | { | ||
| 1086 | "expression": "external_id", | ||
| 1087 | "isExpression": false, | ||
| 1088 | "asc": true, | ||
| 1089 | "nulls": "last" | ||
| 1090 | } | ||
| 1091 | ], | ||
| 1092 | "isUnique": true, | ||
| 1093 | "concurrently": false, | ||
| 1094 | "method": "btree", | ||
| 1095 | "with": {} | ||
| 1096 | } | ||
| 1097 | }, | ||
| 1098 | "foreignKeys": {}, | ||
| 1099 | "compositePrimaryKeys": {}, | ||
| 1100 | "uniqueConstraints": {}, | ||
| 1101 | "policies": {}, | ||
| 1102 | "checkConstraints": {}, | ||
| 1103 | "isRLSEnabled": false | ||
| 1104 | }, | ||
| 1105 | "public.tool": { | ||
| 1106 | "name": "tool", | ||
| 1107 | "schema": "", | ||
| 1108 | "columns": { | ||
| 1109 | "integration": { | ||
| 1110 | "name": "integration", | ||
| 1111 | "type": "varchar(255)", | ||
| 1112 | "primaryKey": false, | ||
| 1113 | "notNull": true | ||
| 1114 | }, | ||
| 1115 | "connection": { | ||
| 1116 | "name": "connection", | ||
| 1117 | "type": "varchar(255)", | ||
| 1118 | "primaryKey": false, | ||
| 1119 | "notNull": true | ||
| 1120 | }, | ||
| 1121 | "plugin_id": { | ||
| 1122 | "name": "plugin_id", | ||
| 1123 | "type": "text", | ||
| 1124 | "primaryKey": false, | ||
| 1125 | "notNull": true | ||
| 1126 | }, | ||
| 1127 | "name": { | ||
| 1128 | "name": "name", | ||
| 1129 | "type": "varchar(255)", | ||
| 1130 | "primaryKey": false, | ||
| 1131 | "notNull": true | ||
| 1132 | }, | ||
| 1133 | "description": { | ||
| 1134 | "name": "description", | ||
| 1135 | "type": "text", | ||
| 1136 | "primaryKey": false, | ||
| 1137 | "notNull": true | ||
| 1138 | }, | ||
| 1139 | "input_schema": { | ||
| 1140 | "name": "input_schema", | ||
| 1141 | "type": "json", | ||
| 1142 | "primaryKey": false, | ||
| 1143 | "notNull": false | ||
| 1144 | }, | ||
| 1145 | "output_schema": { | ||
| 1146 | "name": "output_schema", | ||
| 1147 | "type": "json", | ||
| 1148 | "primaryKey": false, | ||
| 1149 | "notNull": false | ||
| 1150 | }, | ||
| 1151 | "annotations": { | ||
| 1152 | "name": "annotations", | ||
| 1153 | "type": "json", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": false | ||
| 1156 | }, | ||
| 1157 | "created_at": { | ||
| 1158 | "name": "created_at", | ||
| 1159 | "type": "timestamp", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "updated_at": { | ||
| 1164 | "name": "updated_at", | ||
| 1165 | "type": "timestamp", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": true | ||
| 1168 | }, | ||
| 1169 | "row_id": { | ||
| 1170 | "name": "row_id", | ||
| 1171 | "type": "varchar(255)", | ||
| 1172 | "primaryKey": true, | ||
| 1173 | "notNull": true | ||
| 1174 | }, | ||
| 1175 | "tenant": { | ||
| 1176 | "name": "tenant", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "owner": { | ||
| 1182 | "name": "owner", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "subject": { | ||
| 1188 | "name": "subject", | ||
| 1189 | "type": "varchar(255)", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | } | ||
| 1193 | }, | ||
| 1194 | "indexes": { | ||
| 1195 | "tool_uidx": { | ||
| 1196 | "name": "tool_uidx", | ||
| 1197 | "columns": [ | ||
| 1198 | { | ||
| 1199 | "expression": "tenant", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | }, | ||
| 1204 | { | ||
| 1205 | "expression": "owner", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nulls": "last" | ||
| 1209 | }, | ||
| 1210 | { | ||
| 1211 | "expression": "subject", | ||
| 1212 | "isExpression": false, | ||
| 1213 | "asc": true, | ||
| 1214 | "nulls": "last" | ||
| 1215 | }, | ||
| 1216 | { | ||
| 1217 | "expression": "integration", | ||
| 1218 | "isExpression": false, | ||
| 1219 | "asc": true, | ||
| 1220 | "nulls": "last" | ||
| 1221 | }, | ||
| 1222 | { | ||
| 1223 | "expression": "connection", | ||
| 1224 | "isExpression": false, | ||
| 1225 | "asc": true, | ||
| 1226 | "nulls": "last" | ||
| 1227 | }, | ||
| 1228 | { | ||
| 1229 | "expression": "name", | ||
| 1230 | "isExpression": false, | ||
| 1231 | "asc": true, | ||
| 1232 | "nulls": "last" | ||
| 1233 | } | ||
| 1234 | ], | ||
| 1235 | "isUnique": true, | ||
| 1236 | "concurrently": false, | ||
| 1237 | "method": "btree", | ||
| 1238 | "with": {} | ||
| 1239 | } | ||
| 1240 | }, | ||
| 1241 | "foreignKeys": {}, | ||
| 1242 | "compositePrimaryKeys": {}, | ||
| 1243 | "uniqueConstraints": {}, | ||
| 1244 | "policies": {}, | ||
| 1245 | "checkConstraints": {}, | ||
| 1246 | "isRLSEnabled": false | ||
| 1247 | }, | ||
| 1248 | "public.tool_policy": { | ||
| 1249 | "name": "tool_policy", | ||
| 1250 | "schema": "", | ||
| 1251 | "columns": { | ||
| 1252 | "id": { | ||
| 1253 | "name": "id", | ||
| 1254 | "type": "varchar(255)", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": true | ||
| 1257 | }, | ||
| 1258 | "pattern": { | ||
| 1259 | "name": "pattern", | ||
| 1260 | "type": "text", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": true | ||
| 1263 | }, | ||
| 1264 | "action": { | ||
| 1265 | "name": "action", | ||
| 1266 | "type": "text", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": true | ||
| 1269 | }, | ||
| 1270 | "position": { | ||
| 1271 | "name": "position", | ||
| 1272 | "type": "text", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "created_at": { | ||
| 1277 | "name": "created_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "updated_at": { | ||
| 1283 | "name": "updated_at", | ||
| 1284 | "type": "timestamp", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "row_id": { | ||
| 1289 | "name": "row_id", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": true, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "tenant": { | ||
| 1295 | "name": "tenant", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "owner": { | ||
| 1301 | "name": "owner", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | }, | ||
| 1306 | "subject": { | ||
| 1307 | "name": "subject", | ||
| 1308 | "type": "varchar(255)", | ||
| 1309 | "primaryKey": false, | ||
| 1310 | "notNull": true | ||
| 1311 | } | ||
| 1312 | }, | ||
| 1313 | "indexes": { | ||
| 1314 | "tool_policy_uidx": { | ||
| 1315 | "name": "tool_policy_uidx", | ||
| 1316 | "columns": [ | ||
| 1317 | { | ||
| 1318 | "expression": "tenant", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "owner", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "subject", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "id", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | } | ||
| 1341 | ], | ||
| 1342 | "isUnique": true, | ||
| 1343 | "concurrently": false, | ||
| 1344 | "method": "btree", | ||
| 1345 | "with": {} | ||
| 1346 | } | ||
| 1347 | }, | ||
| 1348 | "foreignKeys": {}, | ||
| 1349 | "compositePrimaryKeys": {}, | ||
| 1350 | "uniqueConstraints": {}, | ||
| 1351 | "policies": {}, | ||
| 1352 | "checkConstraints": {}, | ||
| 1353 | "isRLSEnabled": false | ||
| 1354 | } | ||
| 1355 | }, | ||
| 1356 | "enums": {}, | ||
| 1357 | "schemas": {}, | ||
| 1358 | "sequences": {}, | ||
| 1359 | "roles": {}, | ||
| 1360 | "policies": {}, | ||
| 1361 | "views": {}, | ||
| 1362 | "_meta": { | ||
| 1363 | "columns": {}, | ||
| 1364 | "schemas": {}, | ||
| 1365 | "tables": {} | ||
| 1366 | } | ||
| 1367 | } | ||
| @@ -0,0 +1,1474 @@ | |||
| 1 | { | ||
| 2 | "id": "edc90067-822c-443a-8fcd-3bac77bca317", | ||
| 3 | "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "created_at": { | ||
| 172 | "name": "created_at", | ||
| 173 | "type": "timestamp", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | }, | ||
| 177 | "updated_at": { | ||
| 178 | "name": "updated_at", | ||
| 179 | "type": "timestamp", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": true | ||
| 182 | }, | ||
| 183 | "row_id": { | ||
| 184 | "name": "row_id", | ||
| 185 | "type": "varchar(255)", | ||
| 186 | "primaryKey": true, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "tenant": { | ||
| 190 | "name": "tenant", | ||
| 191 | "type": "varchar(255)", | ||
| 192 | "primaryKey": false, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "owner": { | ||
| 196 | "name": "owner", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": false, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "subject": { | ||
| 202 | "name": "subject", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | } | ||
| 207 | }, | ||
| 208 | "indexes": { | ||
| 209 | "artifact_uidx": { | ||
| 210 | "name": "artifact_uidx", | ||
| 211 | "columns": [ | ||
| 212 | { | ||
| 213 | "expression": "tenant", | ||
| 214 | "isExpression": false, | ||
| 215 | "asc": true, | ||
| 216 | "nulls": "last" | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | "expression": "owner", | ||
| 220 | "isExpression": false, | ||
| 221 | "asc": true, | ||
| 222 | "nulls": "last" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "expression": "subject", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "id", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | } | ||
| 236 | ], | ||
| 237 | "isUnique": true, | ||
| 238 | "concurrently": false, | ||
| 239 | "method": "btree", | ||
| 240 | "with": {} | ||
| 241 | } | ||
| 242 | }, | ||
| 243 | "foreignKeys": {}, | ||
| 244 | "compositePrimaryKeys": {}, | ||
| 245 | "uniqueConstraints": {}, | ||
| 246 | "policies": {}, | ||
| 247 | "checkConstraints": {}, | ||
| 248 | "isRLSEnabled": false | ||
| 249 | }, | ||
| 250 | "public.blob": { | ||
| 251 | "name": "blob", | ||
| 252 | "schema": "", | ||
| 253 | "columns": { | ||
| 254 | "namespace": { | ||
| 255 | "name": "namespace", | ||
| 256 | "type": "varchar(255)", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": true | ||
| 259 | }, | ||
| 260 | "key": { | ||
| 261 | "name": "key", | ||
| 262 | "type": "varchar(255)", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": true | ||
| 265 | }, | ||
| 266 | "value": { | ||
| 267 | "name": "value", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "row_id": { | ||
| 273 | "name": "row_id", | ||
| 274 | "type": "varchar(255)", | ||
| 275 | "primaryKey": true, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "id": { | ||
| 279 | "name": "id", | ||
| 280 | "type": "varchar(255)", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": true | ||
| 283 | } | ||
| 284 | }, | ||
| 285 | "indexes": { | ||
| 286 | "blob_id_uidx": { | ||
| 287 | "name": "blob_id_uidx", | ||
| 288 | "columns": [ | ||
| 289 | { | ||
| 290 | "expression": "id", | ||
| 291 | "isExpression": false, | ||
| 292 | "asc": true, | ||
| 293 | "nulls": "last" | ||
| 294 | } | ||
| 295 | ], | ||
| 296 | "isUnique": true, | ||
| 297 | "concurrently": false, | ||
| 298 | "method": "btree", | ||
| 299 | "with": {} | ||
| 300 | } | ||
| 301 | }, | ||
| 302 | "foreignKeys": {}, | ||
| 303 | "compositePrimaryKeys": {}, | ||
| 304 | "uniqueConstraints": {}, | ||
| 305 | "policies": {}, | ||
| 306 | "checkConstraints": {}, | ||
| 307 | "isRLSEnabled": false | ||
| 308 | }, | ||
| 309 | "public.connection": { | ||
| 310 | "name": "connection", | ||
| 311 | "schema": "", | ||
| 312 | "columns": { | ||
| 313 | "integration": { | ||
| 314 | "name": "integration", | ||
| 315 | "type": "varchar(255)", | ||
| 316 | "primaryKey": false, | ||
| 317 | "notNull": true | ||
| 318 | }, | ||
| 319 | "name": { | ||
| 320 | "name": "name", | ||
| 321 | "type": "varchar(255)", | ||
| 322 | "primaryKey": false, | ||
| 323 | "notNull": true | ||
| 324 | }, | ||
| 325 | "template": { | ||
| 326 | "name": "template", | ||
| 327 | "type": "text", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "provider": { | ||
| 332 | "name": "provider", | ||
| 333 | "type": "text", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "item_ids": { | ||
| 338 | "name": "item_ids", | ||
| 339 | "type": "json", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "identity_label": { | ||
| 344 | "name": "identity_label", | ||
| 345 | "type": "text", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": false | ||
| 348 | }, | ||
| 349 | "description": { | ||
| 350 | "name": "description", | ||
| 351 | "type": "text", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": false | ||
| 354 | }, | ||
| 355 | "last_health": { | ||
| 356 | "name": "last_health", | ||
| 357 | "type": "json", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "tools_synced_at": { | ||
| 362 | "name": "tools_synced_at", | ||
| 363 | "type": "bigint", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "oauth_client": { | ||
| 368 | "name": "oauth_client", | ||
| 369 | "type": "text", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "oauth_client_owner": { | ||
| 374 | "name": "oauth_client_owner", | ||
| 375 | "type": "text", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "refresh_item_id": { | ||
| 380 | "name": "refresh_item_id", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "expires_at": { | ||
| 386 | "name": "expires_at", | ||
| 387 | "type": "bigint", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "oauth_scope": { | ||
| 392 | "name": "oauth_scope", | ||
| 393 | "type": "text", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "oauth_token_url": { | ||
| 398 | "name": "oauth_token_url", | ||
| 399 | "type": "text", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "provider_state": { | ||
| 404 | "name": "provider_state", | ||
| 405 | "type": "json", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "created_at": { | ||
| 410 | "name": "created_at", | ||
| 411 | "type": "timestamp", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "updated_at": { | ||
| 416 | "name": "updated_at", | ||
| 417 | "type": "timestamp", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "row_id": { | ||
| 422 | "name": "row_id", | ||
| 423 | "type": "varchar(255)", | ||
| 424 | "primaryKey": true, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "tenant": { | ||
| 428 | "name": "tenant", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": false, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "owner": { | ||
| 434 | "name": "owner", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "subject": { | ||
| 440 | "name": "subject", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | } | ||
| 445 | }, | ||
| 446 | "indexes": { | ||
| 447 | "connection_uidx": { | ||
| 448 | "name": "connection_uidx", | ||
| 449 | "columns": [ | ||
| 450 | { | ||
| 451 | "expression": "tenant", | ||
| 452 | "isExpression": false, | ||
| 453 | "asc": true, | ||
| 454 | "nulls": "last" | ||
| 455 | }, | ||
| 456 | { | ||
| 457 | "expression": "owner", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "subject", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "integration", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "name", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | } | ||
| 480 | ], | ||
| 481 | "isUnique": true, | ||
| 482 | "concurrently": false, | ||
| 483 | "method": "btree", | ||
| 484 | "with": {} | ||
| 485 | } | ||
| 486 | }, | ||
| 487 | "foreignKeys": {}, | ||
| 488 | "compositePrimaryKeys": {}, | ||
| 489 | "uniqueConstraints": {}, | ||
| 490 | "policies": {}, | ||
| 491 | "checkConstraints": {}, | ||
| 492 | "isRLSEnabled": false | ||
| 493 | }, | ||
| 494 | "public.definition": { | ||
| 495 | "name": "definition", | ||
| 496 | "schema": "", | ||
| 497 | "columns": { | ||
| 498 | "integration": { | ||
| 499 | "name": "integration", | ||
| 500 | "type": "varchar(255)", | ||
| 501 | "primaryKey": false, | ||
| 502 | "notNull": true | ||
| 503 | }, | ||
| 504 | "connection": { | ||
| 505 | "name": "connection", | ||
| 506 | "type": "varchar(255)", | ||
| 507 | "primaryKey": false, | ||
| 508 | "notNull": true | ||
| 509 | }, | ||
| 510 | "plugin_id": { | ||
| 511 | "name": "plugin_id", | ||
| 512 | "type": "text", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "name": { | ||
| 517 | "name": "name", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "schema": { | ||
| 523 | "name": "schema", | ||
| 524 | "type": "json", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "created_at": { | ||
| 529 | "name": "created_at", | ||
| 530 | "type": "timestamp", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "row_id": { | ||
| 535 | "name": "row_id", | ||
| 536 | "type": "varchar(255)", | ||
| 537 | "primaryKey": true, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "tenant": { | ||
| 541 | "name": "tenant", | ||
| 542 | "type": "varchar(255)", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "owner": { | ||
| 547 | "name": "owner", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "subject": { | ||
| 553 | "name": "subject", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | } | ||
| 558 | }, | ||
| 559 | "indexes": { | ||
| 560 | "definition_uidx": { | ||
| 561 | "name": "definition_uidx", | ||
| 562 | "columns": [ | ||
| 563 | { | ||
| 564 | "expression": "tenant", | ||
| 565 | "isExpression": false, | ||
| 566 | "asc": true, | ||
| 567 | "nulls": "last" | ||
| 568 | }, | ||
| 569 | { | ||
| 570 | "expression": "owner", | ||
| 571 | "isExpression": false, | ||
| 572 | "asc": true, | ||
| 573 | "nulls": "last" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "expression": "subject", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "integration", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "connection", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "name", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | } | ||
| 599 | ], | ||
| 600 | "isUnique": true, | ||
| 601 | "concurrently": false, | ||
| 602 | "method": "btree", | ||
| 603 | "with": {} | ||
| 604 | } | ||
| 605 | }, | ||
| 606 | "foreignKeys": {}, | ||
| 607 | "compositePrimaryKeys": {}, | ||
| 608 | "uniqueConstraints": {}, | ||
| 609 | "policies": {}, | ||
| 610 | "checkConstraints": {}, | ||
| 611 | "isRLSEnabled": false | ||
| 612 | }, | ||
| 613 | "public.integration": { | ||
| 614 | "name": "integration", | ||
| 615 | "schema": "", | ||
| 616 | "columns": { | ||
| 617 | "slug": { | ||
| 618 | "name": "slug", | ||
| 619 | "type": "varchar(255)", | ||
| 620 | "primaryKey": false, | ||
| 621 | "notNull": true | ||
| 622 | }, | ||
| 623 | "plugin_id": { | ||
| 624 | "name": "plugin_id", | ||
| 625 | "type": "text", | ||
| 626 | "primaryKey": false, | ||
| 627 | "notNull": true | ||
| 628 | }, | ||
| 629 | "name": { | ||
| 630 | "name": "name", | ||
| 631 | "type": "text", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": false | ||
| 634 | }, | ||
| 635 | "description": { | ||
| 636 | "name": "description", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": false | ||
| 640 | }, | ||
| 641 | "config": { | ||
| 642 | "name": "config", | ||
| 643 | "type": "json", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "health_check": { | ||
| 648 | "name": "health_check", | ||
| 649 | "type": "json", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "config_revised_at": { | ||
| 654 | "name": "config_revised_at", | ||
| 655 | "type": "bigint", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "can_remove": { | ||
| 660 | "name": "can_remove", | ||
| 661 | "type": "boolean", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": true, | ||
| 664 | "default": true | ||
| 665 | }, | ||
| 666 | "can_refresh": { | ||
| 667 | "name": "can_refresh", | ||
| 668 | "type": "boolean", | ||
| 669 | "primaryKey": false, | ||
| 670 | "notNull": true, | ||
| 671 | "default": false | ||
| 672 | }, | ||
| 673 | "created_at": { | ||
| 674 | "name": "created_at", | ||
| 675 | "type": "timestamp", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": true | ||
| 678 | }, | ||
| 679 | "updated_at": { | ||
| 680 | "name": "updated_at", | ||
| 681 | "type": "timestamp", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": true | ||
| 684 | }, | ||
| 685 | "row_id": { | ||
| 686 | "name": "row_id", | ||
| 687 | "type": "varchar(255)", | ||
| 688 | "primaryKey": true, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "tenant": { | ||
| 692 | "name": "tenant", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | } | ||
| 697 | }, | ||
| 698 | "indexes": { | ||
| 699 | "integration_uidx": { | ||
| 700 | "name": "integration_uidx", | ||
| 701 | "columns": [ | ||
| 702 | { | ||
| 703 | "expression": "tenant", | ||
| 704 | "isExpression": false, | ||
| 705 | "asc": true, | ||
| 706 | "nulls": "last" | ||
| 707 | }, | ||
| 708 | { | ||
| 709 | "expression": "slug", | ||
| 710 | "isExpression": false, | ||
| 711 | "asc": true, | ||
| 712 | "nulls": "last" | ||
| 713 | } | ||
| 714 | ], | ||
| 715 | "isUnique": true, | ||
| 716 | "concurrently": false, | ||
| 717 | "method": "btree", | ||
| 718 | "with": {} | ||
| 719 | } | ||
| 720 | }, | ||
| 721 | "foreignKeys": {}, | ||
| 722 | "compositePrimaryKeys": {}, | ||
| 723 | "uniqueConstraints": {}, | ||
| 724 | "policies": {}, | ||
| 725 | "checkConstraints": {}, | ||
| 726 | "isRLSEnabled": false | ||
| 727 | }, | ||
| 728 | "public.oauth_client": { | ||
| 729 | "name": "oauth_client", | ||
| 730 | "schema": "", | ||
| 731 | "columns": { | ||
| 732 | "slug": { | ||
| 733 | "name": "slug", | ||
| 734 | "type": "varchar(255)", | ||
| 735 | "primaryKey": false, | ||
| 736 | "notNull": true | ||
| 737 | }, | ||
| 738 | "authorization_url": { | ||
| 739 | "name": "authorization_url", | ||
| 740 | "type": "text", | ||
| 741 | "primaryKey": false, | ||
| 742 | "notNull": true | ||
| 743 | }, | ||
| 744 | "token_url": { | ||
| 745 | "name": "token_url", | ||
| 746 | "type": "text", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "grant": { | ||
| 751 | "name": "grant", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "client_id": { | ||
| 757 | "name": "client_id", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "client_secret_item_id": { | ||
| 763 | "name": "client_secret_item_id", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": false | ||
| 767 | }, | ||
| 768 | "resource": { | ||
| 769 | "name": "resource", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": false | ||
| 773 | }, | ||
| 774 | "origin_kind": { | ||
| 775 | "name": "origin_kind", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "origin_integration": { | ||
| 781 | "name": "origin_integration", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_issuer": { | ||
| 787 | "name": "origin_issuer", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_redirect_uri": { | ||
| 793 | "name": "origin_redirect_uri", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "created_at": { | ||
| 799 | "name": "created_at", | ||
| 800 | "type": "timestamp", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": true | ||
| 803 | }, | ||
| 804 | "row_id": { | ||
| 805 | "name": "row_id", | ||
| 806 | "type": "varchar(255)", | ||
| 807 | "primaryKey": true, | ||
| 808 | "notNull": true | ||
| 809 | }, | ||
| 810 | "tenant": { | ||
| 811 | "name": "tenant", | ||
| 812 | "type": "varchar(255)", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "owner": { | ||
| 817 | "name": "owner", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "subject": { | ||
| 823 | "name": "subject", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | } | ||
| 828 | }, | ||
| 829 | "indexes": { | ||
| 830 | "oauth_client_uidx": { | ||
| 831 | "name": "oauth_client_uidx", | ||
| 832 | "columns": [ | ||
| 833 | { | ||
| 834 | "expression": "tenant", | ||
| 835 | "isExpression": false, | ||
| 836 | "asc": true, | ||
| 837 | "nulls": "last" | ||
| 838 | }, | ||
| 839 | { | ||
| 840 | "expression": "owner", | ||
| 841 | "isExpression": false, | ||
| 842 | "asc": true, | ||
| 843 | "nulls": "last" | ||
| 844 | }, | ||
| 845 | { | ||
| 846 | "expression": "subject", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "slug", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | } | ||
| 857 | ], | ||
| 858 | "isUnique": true, | ||
| 859 | "concurrently": false, | ||
| 860 | "method": "btree", | ||
| 861 | "with": {} | ||
| 862 | } | ||
| 863 | }, | ||
| 864 | "foreignKeys": {}, | ||
| 865 | "compositePrimaryKeys": {}, | ||
| 866 | "uniqueConstraints": {}, | ||
| 867 | "policies": {}, | ||
| 868 | "checkConstraints": {}, | ||
| 869 | "isRLSEnabled": false | ||
| 870 | }, | ||
| 871 | "public.oauth_session": { | ||
| 872 | "name": "oauth_session", | ||
| 873 | "schema": "", | ||
| 874 | "columns": { | ||
| 875 | "state": { | ||
| 876 | "name": "state", | ||
| 877 | "type": "varchar(255)", | ||
| 878 | "primaryKey": false, | ||
| 879 | "notNull": true | ||
| 880 | }, | ||
| 881 | "client_slug": { | ||
| 882 | "name": "client_slug", | ||
| 883 | "type": "text", | ||
| 884 | "primaryKey": false, | ||
| 885 | "notNull": true | ||
| 886 | }, | ||
| 887 | "integration": { | ||
| 888 | "name": "integration", | ||
| 889 | "type": "text", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "name": { | ||
| 894 | "name": "name", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "template": { | ||
| 900 | "name": "template", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "redirect_url": { | ||
| 906 | "name": "redirect_url", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "pkce_verifier": { | ||
| 912 | "name": "pkce_verifier", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": false | ||
| 916 | }, | ||
| 917 | "identity_label": { | ||
| 918 | "name": "identity_label", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": false | ||
| 922 | }, | ||
| 923 | "payload": { | ||
| 924 | "name": "payload", | ||
| 925 | "type": "json", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "expires_at": { | ||
| 930 | "name": "expires_at", | ||
| 931 | "type": "bigint", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "created_at": { | ||
| 936 | "name": "created_at", | ||
| 937 | "type": "timestamp", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "row_id": { | ||
| 942 | "name": "row_id", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": true, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "tenant": { | ||
| 948 | "name": "tenant", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "owner": { | ||
| 954 | "name": "owner", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": false, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "subject": { | ||
| 960 | "name": "subject", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | } | ||
| 965 | }, | ||
| 966 | "indexes": { | ||
| 967 | "oauth_session_uidx": { | ||
| 968 | "name": "oauth_session_uidx", | ||
| 969 | "columns": [ | ||
| 970 | { | ||
| 971 | "expression": "tenant", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "state", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | } | ||
| 982 | ], | ||
| 983 | "isUnique": true, | ||
| 984 | "concurrently": false, | ||
| 985 | "method": "btree", | ||
| 986 | "with": {} | ||
| 987 | } | ||
| 988 | }, | ||
| 989 | "foreignKeys": {}, | ||
| 990 | "compositePrimaryKeys": {}, | ||
| 991 | "uniqueConstraints": {}, | ||
| 992 | "policies": {}, | ||
| 993 | "checkConstraints": {}, | ||
| 994 | "isRLSEnabled": false | ||
| 995 | }, | ||
| 996 | "public.plugin_storage": { | ||
| 997 | "name": "plugin_storage", | ||
| 998 | "schema": "", | ||
| 999 | "columns": { | ||
| 1000 | "plugin_id": { | ||
| 1001 | "name": "plugin_id", | ||
| 1002 | "type": "varchar(255)", | ||
| 1003 | "primaryKey": false, | ||
| 1004 | "notNull": true | ||
| 1005 | }, | ||
| 1006 | "collection": { | ||
| 1007 | "name": "collection", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": false, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "key": { | ||
| 1013 | "name": "key", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "data": { | ||
| 1019 | "name": "data", | ||
| 1020 | "type": "json", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "created_at": { | ||
| 1025 | "name": "created_at", | ||
| 1026 | "type": "timestamp", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "updated_at": { | ||
| 1031 | "name": "updated_at", | ||
| 1032 | "type": "timestamp", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "row_id": { | ||
| 1037 | "name": "row_id", | ||
| 1038 | "type": "varchar(255)", | ||
| 1039 | "primaryKey": true, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "tenant": { | ||
| 1043 | "name": "tenant", | ||
| 1044 | "type": "varchar(255)", | ||
| 1045 | "primaryKey": false, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "owner": { | ||
| 1049 | "name": "owner", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": false, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "subject": { | ||
| 1055 | "name": "subject", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | } | ||
| 1060 | }, | ||
| 1061 | "indexes": { | ||
| 1062 | "plugin_storage_uidx": { | ||
| 1063 | "name": "plugin_storage_uidx", | ||
| 1064 | "columns": [ | ||
| 1065 | { | ||
| 1066 | "expression": "tenant", | ||
| 1067 | "isExpression": false, | ||
| 1068 | "asc": true, | ||
| 1069 | "nulls": "last" | ||
| 1070 | }, | ||
| 1071 | { | ||
| 1072 | "expression": "owner", | ||
| 1073 | "isExpression": false, | ||
| 1074 | "asc": true, | ||
| 1075 | "nulls": "last" | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | "expression": "subject", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "plugin_id", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "collection", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "key", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | } | ||
| 1101 | ], | ||
| 1102 | "isUnique": true, | ||
| 1103 | "concurrently": false, | ||
| 1104 | "method": "btree", | ||
| 1105 | "with": {} | ||
| 1106 | } | ||
| 1107 | }, | ||
| 1108 | "foreignKeys": {}, | ||
| 1109 | "compositePrimaryKeys": {}, | ||
| 1110 | "uniqueConstraints": {}, | ||
| 1111 | "policies": {}, | ||
| 1112 | "checkConstraints": {}, | ||
| 1113 | "isRLSEnabled": false | ||
| 1114 | }, | ||
| 1115 | "public.private_executor_cloud_settings": { | ||
| 1116 | "name": "private_executor_cloud_settings", | ||
| 1117 | "schema": "", | ||
| 1118 | "columns": { | ||
| 1119 | "id": { | ||
| 1120 | "name": "id", | ||
| 1121 | "type": "varchar(255)", | ||
| 1122 | "primaryKey": true, | ||
| 1123 | "notNull": true | ||
| 1124 | }, | ||
| 1125 | "version": { | ||
| 1126 | "name": "version", | ||
| 1127 | "type": "varchar(255)", | ||
| 1128 | "primaryKey": false, | ||
| 1129 | "notNull": true, | ||
| 1130 | "default": "'1.0.0'" | ||
| 1131 | } | ||
| 1132 | }, | ||
| 1133 | "indexes": {}, | ||
| 1134 | "foreignKeys": {}, | ||
| 1135 | "compositePrimaryKeys": {}, | ||
| 1136 | "uniqueConstraints": {}, | ||
| 1137 | "policies": {}, | ||
| 1138 | "checkConstraints": {}, | ||
| 1139 | "isRLSEnabled": false | ||
| 1140 | }, | ||
| 1141 | "public.subject": { | ||
| 1142 | "name": "subject", | ||
| 1143 | "schema": "", | ||
| 1144 | "columns": { | ||
| 1145 | "external_id": { | ||
| 1146 | "name": "external_id", | ||
| 1147 | "type": "varchar(255)", | ||
| 1148 | "primaryKey": false, | ||
| 1149 | "notNull": true | ||
| 1150 | }, | ||
| 1151 | "created_at": { | ||
| 1152 | "name": "created_at", | ||
| 1153 | "type": "timestamp", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": true | ||
| 1156 | }, | ||
| 1157 | "last_seen_at": { | ||
| 1158 | "name": "last_seen_at", | ||
| 1159 | "type": "bigint", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": false | ||
| 1162 | }, | ||
| 1163 | "status": { | ||
| 1164 | "name": "status", | ||
| 1165 | "type": "text", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": false | ||
| 1168 | }, | ||
| 1169 | "row_id": { | ||
| 1170 | "name": "row_id", | ||
| 1171 | "type": "varchar(255)", | ||
| 1172 | "primaryKey": true, | ||
| 1173 | "notNull": true | ||
| 1174 | }, | ||
| 1175 | "tenant": { | ||
| 1176 | "name": "tenant", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | } | ||
| 1181 | }, | ||
| 1182 | "indexes": { | ||
| 1183 | "subject_uidx": { | ||
| 1184 | "name": "subject_uidx", | ||
| 1185 | "columns": [ | ||
| 1186 | { | ||
| 1187 | "expression": "tenant", | ||
| 1188 | "isExpression": false, | ||
| 1189 | "asc": true, | ||
| 1190 | "nulls": "last" | ||
| 1191 | }, | ||
| 1192 | { | ||
| 1193 | "expression": "external_id", | ||
| 1194 | "isExpression": false, | ||
| 1195 | "asc": true, | ||
| 1196 | "nulls": "last" | ||
| 1197 | } | ||
| 1198 | ], | ||
| 1199 | "isUnique": true, | ||
| 1200 | "concurrently": false, | ||
| 1201 | "method": "btree", | ||
| 1202 | "with": {} | ||
| 1203 | } | ||
| 1204 | }, | ||
| 1205 | "foreignKeys": {}, | ||
| 1206 | "compositePrimaryKeys": {}, | ||
| 1207 | "uniqueConstraints": {}, | ||
| 1208 | "policies": {}, | ||
| 1209 | "checkConstraints": {}, | ||
| 1210 | "isRLSEnabled": false | ||
| 1211 | }, | ||
| 1212 | "public.tool": { | ||
| 1213 | "name": "tool", | ||
| 1214 | "schema": "", | ||
| 1215 | "columns": { | ||
| 1216 | "integration": { | ||
| 1217 | "name": "integration", | ||
| 1218 | "type": "varchar(255)", | ||
| 1219 | "primaryKey": false, | ||
| 1220 | "notNull": true | ||
| 1221 | }, | ||
| 1222 | "connection": { | ||
| 1223 | "name": "connection", | ||
| 1224 | "type": "varchar(255)", | ||
| 1225 | "primaryKey": false, | ||
| 1226 | "notNull": true | ||
| 1227 | }, | ||
| 1228 | "plugin_id": { | ||
| 1229 | "name": "plugin_id", | ||
| 1230 | "type": "text", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "name": { | ||
| 1235 | "name": "name", | ||
| 1236 | "type": "varchar(255)", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "description": { | ||
| 1241 | "name": "description", | ||
| 1242 | "type": "text", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "input_schema": { | ||
| 1247 | "name": "input_schema", | ||
| 1248 | "type": "json", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": false | ||
| 1251 | }, | ||
| 1252 | "output_schema": { | ||
| 1253 | "name": "output_schema", | ||
| 1254 | "type": "json", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": false | ||
| 1257 | }, | ||
| 1258 | "annotations": { | ||
| 1259 | "name": "annotations", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "created_at": { | ||
| 1265 | "name": "created_at", | ||
| 1266 | "type": "timestamp", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": true | ||
| 1269 | }, | ||
| 1270 | "updated_at": { | ||
| 1271 | "name": "updated_at", | ||
| 1272 | "type": "timestamp", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "row_id": { | ||
| 1277 | "name": "row_id", | ||
| 1278 | "type": "varchar(255)", | ||
| 1279 | "primaryKey": true, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "tenant": { | ||
| 1283 | "name": "tenant", | ||
| 1284 | "type": "varchar(255)", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "owner": { | ||
| 1289 | "name": "owner", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": false, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "subject": { | ||
| 1295 | "name": "subject", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | } | ||
| 1300 | }, | ||
| 1301 | "indexes": { | ||
| 1302 | "tool_uidx": { | ||
| 1303 | "name": "tool_uidx", | ||
| 1304 | "columns": [ | ||
| 1305 | { | ||
| 1306 | "expression": "tenant", | ||
| 1307 | "isExpression": false, | ||
| 1308 | "asc": true, | ||
| 1309 | "nulls": "last" | ||
| 1310 | }, | ||
| 1311 | { | ||
| 1312 | "expression": "owner", | ||
| 1313 | "isExpression": false, | ||
| 1314 | "asc": true, | ||
| 1315 | "nulls": "last" | ||
| 1316 | }, | ||
| 1317 | { | ||
| 1318 | "expression": "subject", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "integration", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "connection", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "name", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | } | ||
| 1341 | ], | ||
| 1342 | "isUnique": true, | ||
| 1343 | "concurrently": false, | ||
| 1344 | "method": "btree", | ||
| 1345 | "with": {} | ||
| 1346 | } | ||
| 1347 | }, | ||
| 1348 | "foreignKeys": {}, | ||
| 1349 | "compositePrimaryKeys": {}, | ||
| 1350 | "uniqueConstraints": {}, | ||
| 1351 | "policies": {}, | ||
| 1352 | "checkConstraints": {}, | ||
| 1353 | "isRLSEnabled": false | ||
| 1354 | }, | ||
| 1355 | "public.tool_policy": { | ||
| 1356 | "name": "tool_policy", | ||
| 1357 | "schema": "", | ||
| 1358 | "columns": { | ||
| 1359 | "id": { | ||
| 1360 | "name": "id", | ||
| 1361 | "type": "varchar(255)", | ||
| 1362 | "primaryKey": false, | ||
| 1363 | "notNull": true | ||
| 1364 | }, | ||
| 1365 | "pattern": { | ||
| 1366 | "name": "pattern", | ||
| 1367 | "type": "text", | ||
| 1368 | "primaryKey": false, | ||
| 1369 | "notNull": true | ||
| 1370 | }, | ||
| 1371 | "action": { | ||
| 1372 | "name": "action", | ||
| 1373 | "type": "text", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "position": { | ||
| 1378 | "name": "position", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "created_at": { | ||
| 1384 | "name": "created_at", | ||
| 1385 | "type": "timestamp", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "updated_at": { | ||
| 1390 | "name": "updated_at", | ||
| 1391 | "type": "timestamp", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "row_id": { | ||
| 1396 | "name": "row_id", | ||
| 1397 | "type": "varchar(255)", | ||
| 1398 | "primaryKey": true, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "tenant": { | ||
| 1402 | "name": "tenant", | ||
| 1403 | "type": "varchar(255)", | ||
| 1404 | "primaryKey": false, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "owner": { | ||
| 1408 | "name": "owner", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": false, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "subject": { | ||
| 1414 | "name": "subject", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | } | ||
| 1419 | }, | ||
| 1420 | "indexes": { | ||
| 1421 | "tool_policy_uidx": { | ||
| 1422 | "name": "tool_policy_uidx", | ||
| 1423 | "columns": [ | ||
| 1424 | { | ||
| 1425 | "expression": "tenant", | ||
| 1426 | "isExpression": false, | ||
| 1427 | "asc": true, | ||
| 1428 | "nulls": "last" | ||
| 1429 | }, | ||
| 1430 | { | ||
| 1431 | "expression": "owner", | ||
| 1432 | "isExpression": false, | ||
| 1433 | "asc": true, | ||
| 1434 | "nulls": "last" | ||
| 1435 | }, | ||
| 1436 | { | ||
| 1437 | "expression": "subject", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "id", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | } | ||
| 1448 | ], | ||
| 1449 | "isUnique": true, | ||
| 1450 | "concurrently": false, | ||
| 1451 | "method": "btree", | ||
| 1452 | "with": {} | ||
| 1453 | } | ||
| 1454 | }, | ||
| 1455 | "foreignKeys": {}, | ||
| 1456 | "compositePrimaryKeys": {}, | ||
| 1457 | "uniqueConstraints": {}, | ||
| 1458 | "policies": {}, | ||
| 1459 | "checkConstraints": {}, | ||
| 1460 | "isRLSEnabled": false | ||
| 1461 | } | ||
| 1462 | }, | ||
| 1463 | "enums": {}, | ||
| 1464 | "schemas": {}, | ||
| 1465 | "sequences": {}, | ||
| 1466 | "roles": {}, | ||
| 1467 | "policies": {}, | ||
| 1468 | "views": {}, | ||
| 1469 | "_meta": { | ||
| 1470 | "columns": {}, | ||
| 1471 | "schemas": {}, | ||
| 1472 | "tables": {} | ||
| 1473 | } | ||
| 1474 | } | ||
| @@ -0,0 +1,1480 @@ | |||
| 1 | { | ||
| 2 | "id": "0857c83f-0124-4739-bd88-a0a4a92aab84", | ||
| 3 | "prevId": "edc90067-822c-443a-8fcd-3bac77bca317", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "bindings": { | ||
| 172 | "name": "bindings", | ||
| 173 | "type": "json", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": false | ||
| 176 | }, | ||
| 177 | "created_at": { | ||
| 178 | "name": "created_at", | ||
| 179 | "type": "timestamp", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": true | ||
| 182 | }, | ||
| 183 | "updated_at": { | ||
| 184 | "name": "updated_at", | ||
| 185 | "type": "timestamp", | ||
| 186 | "primaryKey": false, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "row_id": { | ||
| 190 | "name": "row_id", | ||
| 191 | "type": "varchar(255)", | ||
| 192 | "primaryKey": true, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "tenant": { | ||
| 196 | "name": "tenant", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": false, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "owner": { | ||
| 202 | "name": "owner", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | }, | ||
| 207 | "subject": { | ||
| 208 | "name": "subject", | ||
| 209 | "type": "varchar(255)", | ||
| 210 | "primaryKey": false, | ||
| 211 | "notNull": true | ||
| 212 | } | ||
| 213 | }, | ||
| 214 | "indexes": { | ||
| 215 | "artifact_uidx": { | ||
| 216 | "name": "artifact_uidx", | ||
| 217 | "columns": [ | ||
| 218 | { | ||
| 219 | "expression": "tenant", | ||
| 220 | "isExpression": false, | ||
| 221 | "asc": true, | ||
| 222 | "nulls": "last" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "expression": "owner", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "subject", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | }, | ||
| 236 | { | ||
| 237 | "expression": "id", | ||
| 238 | "isExpression": false, | ||
| 239 | "asc": true, | ||
| 240 | "nulls": "last" | ||
| 241 | } | ||
| 242 | ], | ||
| 243 | "isUnique": true, | ||
| 244 | "concurrently": false, | ||
| 245 | "method": "btree", | ||
| 246 | "with": {} | ||
| 247 | } | ||
| 248 | }, | ||
| 249 | "foreignKeys": {}, | ||
| 250 | "compositePrimaryKeys": {}, | ||
| 251 | "uniqueConstraints": {}, | ||
| 252 | "policies": {}, | ||
| 253 | "checkConstraints": {}, | ||
| 254 | "isRLSEnabled": false | ||
| 255 | }, | ||
| 256 | "public.blob": { | ||
| 257 | "name": "blob", | ||
| 258 | "schema": "", | ||
| 259 | "columns": { | ||
| 260 | "namespace": { | ||
| 261 | "name": "namespace", | ||
| 262 | "type": "varchar(255)", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": true | ||
| 265 | }, | ||
| 266 | "key": { | ||
| 267 | "name": "key", | ||
| 268 | "type": "varchar(255)", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "value": { | ||
| 273 | "name": "value", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "row_id": { | ||
| 279 | "name": "row_id", | ||
| 280 | "type": "varchar(255)", | ||
| 281 | "primaryKey": true, | ||
| 282 | "notNull": true | ||
| 283 | }, | ||
| 284 | "id": { | ||
| 285 | "name": "id", | ||
| 286 | "type": "varchar(255)", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": true | ||
| 289 | } | ||
| 290 | }, | ||
| 291 | "indexes": { | ||
| 292 | "blob_id_uidx": { | ||
| 293 | "name": "blob_id_uidx", | ||
| 294 | "columns": [ | ||
| 295 | { | ||
| 296 | "expression": "id", | ||
| 297 | "isExpression": false, | ||
| 298 | "asc": true, | ||
| 299 | "nulls": "last" | ||
| 300 | } | ||
| 301 | ], | ||
| 302 | "isUnique": true, | ||
| 303 | "concurrently": false, | ||
| 304 | "method": "btree", | ||
| 305 | "with": {} | ||
| 306 | } | ||
| 307 | }, | ||
| 308 | "foreignKeys": {}, | ||
| 309 | "compositePrimaryKeys": {}, | ||
| 310 | "uniqueConstraints": {}, | ||
| 311 | "policies": {}, | ||
| 312 | "checkConstraints": {}, | ||
| 313 | "isRLSEnabled": false | ||
| 314 | }, | ||
| 315 | "public.connection": { | ||
| 316 | "name": "connection", | ||
| 317 | "schema": "", | ||
| 318 | "columns": { | ||
| 319 | "integration": { | ||
| 320 | "name": "integration", | ||
| 321 | "type": "varchar(255)", | ||
| 322 | "primaryKey": false, | ||
| 323 | "notNull": true | ||
| 324 | }, | ||
| 325 | "name": { | ||
| 326 | "name": "name", | ||
| 327 | "type": "varchar(255)", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "template": { | ||
| 332 | "name": "template", | ||
| 333 | "type": "text", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "provider": { | ||
| 338 | "name": "provider", | ||
| 339 | "type": "text", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "item_ids": { | ||
| 344 | "name": "item_ids", | ||
| 345 | "type": "json", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": true | ||
| 348 | }, | ||
| 349 | "identity_label": { | ||
| 350 | "name": "identity_label", | ||
| 351 | "type": "text", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": false | ||
| 354 | }, | ||
| 355 | "description": { | ||
| 356 | "name": "description", | ||
| 357 | "type": "text", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "last_health": { | ||
| 362 | "name": "last_health", | ||
| 363 | "type": "json", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "tools_synced_at": { | ||
| 368 | "name": "tools_synced_at", | ||
| 369 | "type": "bigint", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "oauth_client": { | ||
| 374 | "name": "oauth_client", | ||
| 375 | "type": "text", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "oauth_client_owner": { | ||
| 380 | "name": "oauth_client_owner", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "refresh_item_id": { | ||
| 386 | "name": "refresh_item_id", | ||
| 387 | "type": "text", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "expires_at": { | ||
| 392 | "name": "expires_at", | ||
| 393 | "type": "bigint", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "oauth_scope": { | ||
| 398 | "name": "oauth_scope", | ||
| 399 | "type": "text", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "oauth_token_url": { | ||
| 404 | "name": "oauth_token_url", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "provider_state": { | ||
| 410 | "name": "provider_state", | ||
| 411 | "type": "json", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": false | ||
| 414 | }, | ||
| 415 | "created_at": { | ||
| 416 | "name": "created_at", | ||
| 417 | "type": "timestamp", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "updated_at": { | ||
| 422 | "name": "updated_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "connection_uidx": { | ||
| 454 | "name": "connection_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "name", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | } | ||
| 486 | ], | ||
| 487 | "isUnique": true, | ||
| 488 | "concurrently": false, | ||
| 489 | "method": "btree", | ||
| 490 | "with": {} | ||
| 491 | } | ||
| 492 | }, | ||
| 493 | "foreignKeys": {}, | ||
| 494 | "compositePrimaryKeys": {}, | ||
| 495 | "uniqueConstraints": {}, | ||
| 496 | "policies": {}, | ||
| 497 | "checkConstraints": {}, | ||
| 498 | "isRLSEnabled": false | ||
| 499 | }, | ||
| 500 | "public.definition": { | ||
| 501 | "name": "definition", | ||
| 502 | "schema": "", | ||
| 503 | "columns": { | ||
| 504 | "integration": { | ||
| 505 | "name": "integration", | ||
| 506 | "type": "varchar(255)", | ||
| 507 | "primaryKey": false, | ||
| 508 | "notNull": true | ||
| 509 | }, | ||
| 510 | "connection": { | ||
| 511 | "name": "connection", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "schema": { | ||
| 529 | "name": "schema", | ||
| 530 | "type": "json", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "created_at": { | ||
| 535 | "name": "created_at", | ||
| 536 | "type": "timestamp", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "row_id": { | ||
| 541 | "name": "row_id", | ||
| 542 | "type": "varchar(255)", | ||
| 543 | "primaryKey": true, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "tenant": { | ||
| 547 | "name": "tenant", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "owner": { | ||
| 553 | "name": "owner", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | }, | ||
| 558 | "subject": { | ||
| 559 | "name": "subject", | ||
| 560 | "type": "varchar(255)", | ||
| 561 | "primaryKey": false, | ||
| 562 | "notNull": true | ||
| 563 | } | ||
| 564 | }, | ||
| 565 | "indexes": { | ||
| 566 | "definition_uidx": { | ||
| 567 | "name": "definition_uidx", | ||
| 568 | "columns": [ | ||
| 569 | { | ||
| 570 | "expression": "tenant", | ||
| 571 | "isExpression": false, | ||
| 572 | "asc": true, | ||
| 573 | "nulls": "last" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "expression": "owner", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "subject", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "integration", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "connection", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | }, | ||
| 599 | { | ||
| 600 | "expression": "name", | ||
| 601 | "isExpression": false, | ||
| 602 | "asc": true, | ||
| 603 | "nulls": "last" | ||
| 604 | } | ||
| 605 | ], | ||
| 606 | "isUnique": true, | ||
| 607 | "concurrently": false, | ||
| 608 | "method": "btree", | ||
| 609 | "with": {} | ||
| 610 | } | ||
| 611 | }, | ||
| 612 | "foreignKeys": {}, | ||
| 613 | "compositePrimaryKeys": {}, | ||
| 614 | "uniqueConstraints": {}, | ||
| 615 | "policies": {}, | ||
| 616 | "checkConstraints": {}, | ||
| 617 | "isRLSEnabled": false | ||
| 618 | }, | ||
| 619 | "public.integration": { | ||
| 620 | "name": "integration", | ||
| 621 | "schema": "", | ||
| 622 | "columns": { | ||
| 623 | "slug": { | ||
| 624 | "name": "slug", | ||
| 625 | "type": "varchar(255)", | ||
| 626 | "primaryKey": false, | ||
| 627 | "notNull": true | ||
| 628 | }, | ||
| 629 | "plugin_id": { | ||
| 630 | "name": "plugin_id", | ||
| 631 | "type": "text", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": true | ||
| 634 | }, | ||
| 635 | "name": { | ||
| 636 | "name": "name", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": false | ||
| 640 | }, | ||
| 641 | "description": { | ||
| 642 | "name": "description", | ||
| 643 | "type": "text", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "config": { | ||
| 648 | "name": "config", | ||
| 649 | "type": "json", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "health_check": { | ||
| 654 | "name": "health_check", | ||
| 655 | "type": "json", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "config_revised_at": { | ||
| 660 | "name": "config_revised_at", | ||
| 661 | "type": "bigint", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": false | ||
| 664 | }, | ||
| 665 | "can_remove": { | ||
| 666 | "name": "can_remove", | ||
| 667 | "type": "boolean", | ||
| 668 | "primaryKey": false, | ||
| 669 | "notNull": true, | ||
| 670 | "default": true | ||
| 671 | }, | ||
| 672 | "can_refresh": { | ||
| 673 | "name": "can_refresh", | ||
| 674 | "type": "boolean", | ||
| 675 | "primaryKey": false, | ||
| 676 | "notNull": true, | ||
| 677 | "default": false | ||
| 678 | }, | ||
| 679 | "created_at": { | ||
| 680 | "name": "created_at", | ||
| 681 | "type": "timestamp", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": true | ||
| 684 | }, | ||
| 685 | "updated_at": { | ||
| 686 | "name": "updated_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "row_id": { | ||
| 692 | "name": "row_id", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": true, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "tenant": { | ||
| 698 | "name": "tenant", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": false, | ||
| 701 | "notNull": true | ||
| 702 | } | ||
| 703 | }, | ||
| 704 | "indexes": { | ||
| 705 | "integration_uidx": { | ||
| 706 | "name": "integration_uidx", | ||
| 707 | "columns": [ | ||
| 708 | { | ||
| 709 | "expression": "tenant", | ||
| 710 | "isExpression": false, | ||
| 711 | "asc": true, | ||
| 712 | "nulls": "last" | ||
| 713 | }, | ||
| 714 | { | ||
| 715 | "expression": "slug", | ||
| 716 | "isExpression": false, | ||
| 717 | "asc": true, | ||
| 718 | "nulls": "last" | ||
| 719 | } | ||
| 720 | ], | ||
| 721 | "isUnique": true, | ||
| 722 | "concurrently": false, | ||
| 723 | "method": "btree", | ||
| 724 | "with": {} | ||
| 725 | } | ||
| 726 | }, | ||
| 727 | "foreignKeys": {}, | ||
| 728 | "compositePrimaryKeys": {}, | ||
| 729 | "uniqueConstraints": {}, | ||
| 730 | "policies": {}, | ||
| 731 | "checkConstraints": {}, | ||
| 732 | "isRLSEnabled": false | ||
| 733 | }, | ||
| 734 | "public.oauth_client": { | ||
| 735 | "name": "oauth_client", | ||
| 736 | "schema": "", | ||
| 737 | "columns": { | ||
| 738 | "slug": { | ||
| 739 | "name": "slug", | ||
| 740 | "type": "varchar(255)", | ||
| 741 | "primaryKey": false, | ||
| 742 | "notNull": true | ||
| 743 | }, | ||
| 744 | "authorization_url": { | ||
| 745 | "name": "authorization_url", | ||
| 746 | "type": "text", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "token_url": { | ||
| 751 | "name": "token_url", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "grant": { | ||
| 757 | "name": "grant", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "client_id": { | ||
| 763 | "name": "client_id", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_secret_item_id": { | ||
| 769 | "name": "client_secret_item_id", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": false | ||
| 773 | }, | ||
| 774 | "resource": { | ||
| 775 | "name": "resource", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "origin_kind": { | ||
| 781 | "name": "origin_kind", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_integration": { | ||
| 787 | "name": "origin_integration", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_issuer": { | ||
| 793 | "name": "origin_issuer", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "origin_redirect_uri": { | ||
| 799 | "name": "origin_redirect_uri", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "created_at": { | ||
| 805 | "name": "created_at", | ||
| 806 | "type": "timestamp", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": true | ||
| 809 | }, | ||
| 810 | "row_id": { | ||
| 811 | "name": "row_id", | ||
| 812 | "type": "varchar(255)", | ||
| 813 | "primaryKey": true, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "tenant": { | ||
| 817 | "name": "tenant", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "owner": { | ||
| 823 | "name": "owner", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "subject": { | ||
| 829 | "name": "subject", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | } | ||
| 834 | }, | ||
| 835 | "indexes": { | ||
| 836 | "oauth_client_uidx": { | ||
| 837 | "name": "oauth_client_uidx", | ||
| 838 | "columns": [ | ||
| 839 | { | ||
| 840 | "expression": "tenant", | ||
| 841 | "isExpression": false, | ||
| 842 | "asc": true, | ||
| 843 | "nulls": "last" | ||
| 844 | }, | ||
| 845 | { | ||
| 846 | "expression": "owner", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "subject", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | }, | ||
| 857 | { | ||
| 858 | "expression": "slug", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | } | ||
| 863 | ], | ||
| 864 | "isUnique": true, | ||
| 865 | "concurrently": false, | ||
| 866 | "method": "btree", | ||
| 867 | "with": {} | ||
| 868 | } | ||
| 869 | }, | ||
| 870 | "foreignKeys": {}, | ||
| 871 | "compositePrimaryKeys": {}, | ||
| 872 | "uniqueConstraints": {}, | ||
| 873 | "policies": {}, | ||
| 874 | "checkConstraints": {}, | ||
| 875 | "isRLSEnabled": false | ||
| 876 | }, | ||
| 877 | "public.oauth_session": { | ||
| 878 | "name": "oauth_session", | ||
| 879 | "schema": "", | ||
| 880 | "columns": { | ||
| 881 | "state": { | ||
| 882 | "name": "state", | ||
| 883 | "type": "varchar(255)", | ||
| 884 | "primaryKey": false, | ||
| 885 | "notNull": true | ||
| 886 | }, | ||
| 887 | "client_slug": { | ||
| 888 | "name": "client_slug", | ||
| 889 | "type": "text", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "integration": { | ||
| 894 | "name": "integration", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "name": { | ||
| 900 | "name": "name", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "template": { | ||
| 906 | "name": "template", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "redirect_url": { | ||
| 912 | "name": "redirect_url", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "pkce_verifier": { | ||
| 918 | "name": "pkce_verifier", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": false | ||
| 922 | }, | ||
| 923 | "identity_label": { | ||
| 924 | "name": "identity_label", | ||
| 925 | "type": "text", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": false | ||
| 928 | }, | ||
| 929 | "payload": { | ||
| 930 | "name": "payload", | ||
| 931 | "type": "json", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "expires_at": { | ||
| 936 | "name": "expires_at", | ||
| 937 | "type": "bigint", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "created_at": { | ||
| 942 | "name": "created_at", | ||
| 943 | "type": "timestamp", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "row_id": { | ||
| 948 | "name": "row_id", | ||
| 949 | "type": "varchar(255)", | ||
| 950 | "primaryKey": true, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "tenant": { | ||
| 954 | "name": "tenant", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": false, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "owner": { | ||
| 960 | "name": "owner", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | }, | ||
| 965 | "subject": { | ||
| 966 | "name": "subject", | ||
| 967 | "type": "varchar(255)", | ||
| 968 | "primaryKey": false, | ||
| 969 | "notNull": true | ||
| 970 | } | ||
| 971 | }, | ||
| 972 | "indexes": { | ||
| 973 | "oauth_session_uidx": { | ||
| 974 | "name": "oauth_session_uidx", | ||
| 975 | "columns": [ | ||
| 976 | { | ||
| 977 | "expression": "tenant", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "state", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | } | ||
| 988 | ], | ||
| 989 | "isUnique": true, | ||
| 990 | "concurrently": false, | ||
| 991 | "method": "btree", | ||
| 992 | "with": {} | ||
| 993 | } | ||
| 994 | }, | ||
| 995 | "foreignKeys": {}, | ||
| 996 | "compositePrimaryKeys": {}, | ||
| 997 | "uniqueConstraints": {}, | ||
| 998 | "policies": {}, | ||
| 999 | "checkConstraints": {}, | ||
| 1000 | "isRLSEnabled": false | ||
| 1001 | }, | ||
| 1002 | "public.plugin_storage": { | ||
| 1003 | "name": "plugin_storage", | ||
| 1004 | "schema": "", | ||
| 1005 | "columns": { | ||
| 1006 | "plugin_id": { | ||
| 1007 | "name": "plugin_id", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": false, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "collection": { | ||
| 1013 | "name": "collection", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "key": { | ||
| 1019 | "name": "key", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "data": { | ||
| 1025 | "name": "data", | ||
| 1026 | "type": "json", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "created_at": { | ||
| 1031 | "name": "created_at", | ||
| 1032 | "type": "timestamp", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "updated_at": { | ||
| 1037 | "name": "updated_at", | ||
| 1038 | "type": "timestamp", | ||
| 1039 | "primaryKey": false, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "row_id": { | ||
| 1043 | "name": "row_id", | ||
| 1044 | "type": "varchar(255)", | ||
| 1045 | "primaryKey": true, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "tenant": { | ||
| 1049 | "name": "tenant", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": false, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "owner": { | ||
| 1055 | "name": "owner", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | }, | ||
| 1060 | "subject": { | ||
| 1061 | "name": "subject", | ||
| 1062 | "type": "varchar(255)", | ||
| 1063 | "primaryKey": false, | ||
| 1064 | "notNull": true | ||
| 1065 | } | ||
| 1066 | }, | ||
| 1067 | "indexes": { | ||
| 1068 | "plugin_storage_uidx": { | ||
| 1069 | "name": "plugin_storage_uidx", | ||
| 1070 | "columns": [ | ||
| 1071 | { | ||
| 1072 | "expression": "tenant", | ||
| 1073 | "isExpression": false, | ||
| 1074 | "asc": true, | ||
| 1075 | "nulls": "last" | ||
| 1076 | }, | ||
| 1077 | { | ||
| 1078 | "expression": "owner", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "subject", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "plugin_id", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "collection", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | }, | ||
| 1101 | { | ||
| 1102 | "expression": "key", | ||
| 1103 | "isExpression": false, | ||
| 1104 | "asc": true, | ||
| 1105 | "nulls": "last" | ||
| 1106 | } | ||
| 1107 | ], | ||
| 1108 | "isUnique": true, | ||
| 1109 | "concurrently": false, | ||
| 1110 | "method": "btree", | ||
| 1111 | "with": {} | ||
| 1112 | } | ||
| 1113 | }, | ||
| 1114 | "foreignKeys": {}, | ||
| 1115 | "compositePrimaryKeys": {}, | ||
| 1116 | "uniqueConstraints": {}, | ||
| 1117 | "policies": {}, | ||
| 1118 | "checkConstraints": {}, | ||
| 1119 | "isRLSEnabled": false | ||
| 1120 | }, | ||
| 1121 | "public.private_executor_cloud_settings": { | ||
| 1122 | "name": "private_executor_cloud_settings", | ||
| 1123 | "schema": "", | ||
| 1124 | "columns": { | ||
| 1125 | "id": { | ||
| 1126 | "name": "id", | ||
| 1127 | "type": "varchar(255)", | ||
| 1128 | "primaryKey": true, | ||
| 1129 | "notNull": true | ||
| 1130 | }, | ||
| 1131 | "version": { | ||
| 1132 | "name": "version", | ||
| 1133 | "type": "varchar(255)", | ||
| 1134 | "primaryKey": false, | ||
| 1135 | "notNull": true, | ||
| 1136 | "default": "'1.0.0'" | ||
| 1137 | } | ||
| 1138 | }, | ||
| 1139 | "indexes": {}, | ||
| 1140 | "foreignKeys": {}, | ||
| 1141 | "compositePrimaryKeys": {}, | ||
| 1142 | "uniqueConstraints": {}, | ||
| 1143 | "policies": {}, | ||
| 1144 | "checkConstraints": {}, | ||
| 1145 | "isRLSEnabled": false | ||
| 1146 | }, | ||
| 1147 | "public.subject": { | ||
| 1148 | "name": "subject", | ||
| 1149 | "schema": "", | ||
| 1150 | "columns": { | ||
| 1151 | "external_id": { | ||
| 1152 | "name": "external_id", | ||
| 1153 | "type": "varchar(255)", | ||
| 1154 | "primaryKey": false, | ||
| 1155 | "notNull": true | ||
| 1156 | }, | ||
| 1157 | "created_at": { | ||
| 1158 | "name": "created_at", | ||
| 1159 | "type": "timestamp", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "last_seen_at": { | ||
| 1164 | "name": "last_seen_at", | ||
| 1165 | "type": "bigint", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": false | ||
| 1168 | }, | ||
| 1169 | "status": { | ||
| 1170 | "name": "status", | ||
| 1171 | "type": "text", | ||
| 1172 | "primaryKey": false, | ||
| 1173 | "notNull": false | ||
| 1174 | }, | ||
| 1175 | "row_id": { | ||
| 1176 | "name": "row_id", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": true, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "tenant": { | ||
| 1182 | "name": "tenant", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | } | ||
| 1187 | }, | ||
| 1188 | "indexes": { | ||
| 1189 | "subject_uidx": { | ||
| 1190 | "name": "subject_uidx", | ||
| 1191 | "columns": [ | ||
| 1192 | { | ||
| 1193 | "expression": "tenant", | ||
| 1194 | "isExpression": false, | ||
| 1195 | "asc": true, | ||
| 1196 | "nulls": "last" | ||
| 1197 | }, | ||
| 1198 | { | ||
| 1199 | "expression": "external_id", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | } | ||
| 1204 | ], | ||
| 1205 | "isUnique": true, | ||
| 1206 | "concurrently": false, | ||
| 1207 | "method": "btree", | ||
| 1208 | "with": {} | ||
| 1209 | } | ||
| 1210 | }, | ||
| 1211 | "foreignKeys": {}, | ||
| 1212 | "compositePrimaryKeys": {}, | ||
| 1213 | "uniqueConstraints": {}, | ||
| 1214 | "policies": {}, | ||
| 1215 | "checkConstraints": {}, | ||
| 1216 | "isRLSEnabled": false | ||
| 1217 | }, | ||
| 1218 | "public.tool": { | ||
| 1219 | "name": "tool", | ||
| 1220 | "schema": "", | ||
| 1221 | "columns": { | ||
| 1222 | "integration": { | ||
| 1223 | "name": "integration", | ||
| 1224 | "type": "varchar(255)", | ||
| 1225 | "primaryKey": false, | ||
| 1226 | "notNull": true | ||
| 1227 | }, | ||
| 1228 | "connection": { | ||
| 1229 | "name": "connection", | ||
| 1230 | "type": "varchar(255)", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "plugin_id": { | ||
| 1235 | "name": "plugin_id", | ||
| 1236 | "type": "text", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "name": { | ||
| 1241 | "name": "name", | ||
| 1242 | "type": "varchar(255)", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "description": { | ||
| 1247 | "name": "description", | ||
| 1248 | "type": "text", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": true | ||
| 1251 | }, | ||
| 1252 | "input_schema": { | ||
| 1253 | "name": "input_schema", | ||
| 1254 | "type": "json", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": false | ||
| 1257 | }, | ||
| 1258 | "output_schema": { | ||
| 1259 | "name": "output_schema", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "annotations": { | ||
| 1265 | "name": "annotations", | ||
| 1266 | "type": "json", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": false | ||
| 1269 | }, | ||
| 1270 | "created_at": { | ||
| 1271 | "name": "created_at", | ||
| 1272 | "type": "timestamp", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": true | ||
| 1275 | }, | ||
| 1276 | "updated_at": { | ||
| 1277 | "name": "updated_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "row_id": { | ||
| 1283 | "name": "row_id", | ||
| 1284 | "type": "varchar(255)", | ||
| 1285 | "primaryKey": true, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "tenant": { | ||
| 1289 | "name": "tenant", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": false, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "owner": { | ||
| 1295 | "name": "owner", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "subject": { | ||
| 1301 | "name": "subject", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | } | ||
| 1306 | }, | ||
| 1307 | "indexes": { | ||
| 1308 | "tool_uidx": { | ||
| 1309 | "name": "tool_uidx", | ||
| 1310 | "columns": [ | ||
| 1311 | { | ||
| 1312 | "expression": "tenant", | ||
| 1313 | "isExpression": false, | ||
| 1314 | "asc": true, | ||
| 1315 | "nulls": "last" | ||
| 1316 | }, | ||
| 1317 | { | ||
| 1318 | "expression": "owner", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "subject", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "integration", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "connection", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | }, | ||
| 1341 | { | ||
| 1342 | "expression": "name", | ||
| 1343 | "isExpression": false, | ||
| 1344 | "asc": true, | ||
| 1345 | "nulls": "last" | ||
| 1346 | } | ||
| 1347 | ], | ||
| 1348 | "isUnique": true, | ||
| 1349 | "concurrently": false, | ||
| 1350 | "method": "btree", | ||
| 1351 | "with": {} | ||
| 1352 | } | ||
| 1353 | }, | ||
| 1354 | "foreignKeys": {}, | ||
| 1355 | "compositePrimaryKeys": {}, | ||
| 1356 | "uniqueConstraints": {}, | ||
| 1357 | "policies": {}, | ||
| 1358 | "checkConstraints": {}, | ||
| 1359 | "isRLSEnabled": false | ||
| 1360 | }, | ||
| 1361 | "public.tool_policy": { | ||
| 1362 | "name": "tool_policy", | ||
| 1363 | "schema": "", | ||
| 1364 | "columns": { | ||
| 1365 | "id": { | ||
| 1366 | "name": "id", | ||
| 1367 | "type": "varchar(255)", | ||
| 1368 | "primaryKey": false, | ||
| 1369 | "notNull": true | ||
| 1370 | }, | ||
| 1371 | "pattern": { | ||
| 1372 | "name": "pattern", | ||
| 1373 | "type": "text", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "action": { | ||
| 1378 | "name": "action", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "position": { | ||
| 1384 | "name": "position", | ||
| 1385 | "type": "text", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "created_at": { | ||
| 1390 | "name": "created_at", | ||
| 1391 | "type": "timestamp", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "updated_at": { | ||
| 1396 | "name": "updated_at", | ||
| 1397 | "type": "timestamp", | ||
| 1398 | "primaryKey": false, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "row_id": { | ||
| 1402 | "name": "row_id", | ||
| 1403 | "type": "varchar(255)", | ||
| 1404 | "primaryKey": true, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "tenant": { | ||
| 1408 | "name": "tenant", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": false, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "owner": { | ||
| 1414 | "name": "owner", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | }, | ||
| 1419 | "subject": { | ||
| 1420 | "name": "subject", | ||
| 1421 | "type": "varchar(255)", | ||
| 1422 | "primaryKey": false, | ||
| 1423 | "notNull": true | ||
| 1424 | } | ||
| 1425 | }, | ||
| 1426 | "indexes": { | ||
| 1427 | "tool_policy_uidx": { | ||
| 1428 | "name": "tool_policy_uidx", | ||
| 1429 | "columns": [ | ||
| 1430 | { | ||
| 1431 | "expression": "tenant", | ||
| 1432 | "isExpression": false, | ||
| 1433 | "asc": true, | ||
| 1434 | "nulls": "last" | ||
| 1435 | }, | ||
| 1436 | { | ||
| 1437 | "expression": "owner", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "subject", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | }, | ||
| 1448 | { | ||
| 1449 | "expression": "id", | ||
| 1450 | "isExpression": false, | ||
| 1451 | "asc": true, | ||
| 1452 | "nulls": "last" | ||
| 1453 | } | ||
| 1454 | ], | ||
| 1455 | "isUnique": true, | ||
| 1456 | "concurrently": false, | ||
| 1457 | "method": "btree", | ||
| 1458 | "with": {} | ||
| 1459 | } | ||
| 1460 | }, | ||
| 1461 | "foreignKeys": {}, | ||
| 1462 | "compositePrimaryKeys": {}, | ||
| 1463 | "uniqueConstraints": {}, | ||
| 1464 | "policies": {}, | ||
| 1465 | "checkConstraints": {}, | ||
| 1466 | "isRLSEnabled": false | ||
| 1467 | } | ||
| 1468 | }, | ||
| 1469 | "enums": {}, | ||
| 1470 | "schemas": {}, | ||
| 1471 | "sequences": {}, | ||
| 1472 | "roles": {}, | ||
| 1473 | "policies": {}, | ||
| 1474 | "views": {}, | ||
| 1475 | "_meta": { | ||
| 1476 | "columns": {}, | ||
| 1477 | "schemas": {}, | ||
| 1478 | "tables": {} | ||
| 1479 | } | ||
| 1480 | } | ||
| @@ -0,0 +1,1486 @@ | |||
| 1 | { | ||
| 2 | "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", | ||
| 3 | "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.artifact": { | ||
| 144 | "name": "artifact", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "id": { | ||
| 148 | "name": "id", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "title": { | ||
| 154 | "name": "title", | ||
| 155 | "type": "text", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "description": { | ||
| 160 | "name": "description", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": false | ||
| 164 | }, | ||
| 165 | "code": { | ||
| 166 | "name": "code", | ||
| 167 | "type": "text", | ||
| 168 | "primaryKey": false, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "bindings": { | ||
| 172 | "name": "bindings", | ||
| 173 | "type": "json", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": false | ||
| 176 | }, | ||
| 177 | "preview": { | ||
| 178 | "name": "preview", | ||
| 179 | "type": "text", | ||
| 180 | "primaryKey": false, | ||
| 181 | "notNull": false | ||
| 182 | }, | ||
| 183 | "created_at": { | ||
| 184 | "name": "created_at", | ||
| 185 | "type": "timestamp", | ||
| 186 | "primaryKey": false, | ||
| 187 | "notNull": true | ||
| 188 | }, | ||
| 189 | "updated_at": { | ||
| 190 | "name": "updated_at", | ||
| 191 | "type": "timestamp", | ||
| 192 | "primaryKey": false, | ||
| 193 | "notNull": true | ||
| 194 | }, | ||
| 195 | "row_id": { | ||
| 196 | "name": "row_id", | ||
| 197 | "type": "varchar(255)", | ||
| 198 | "primaryKey": true, | ||
| 199 | "notNull": true | ||
| 200 | }, | ||
| 201 | "tenant": { | ||
| 202 | "name": "tenant", | ||
| 203 | "type": "varchar(255)", | ||
| 204 | "primaryKey": false, | ||
| 205 | "notNull": true | ||
| 206 | }, | ||
| 207 | "owner": { | ||
| 208 | "name": "owner", | ||
| 209 | "type": "varchar(255)", | ||
| 210 | "primaryKey": false, | ||
| 211 | "notNull": true | ||
| 212 | }, | ||
| 213 | "subject": { | ||
| 214 | "name": "subject", | ||
| 215 | "type": "varchar(255)", | ||
| 216 | "primaryKey": false, | ||
| 217 | "notNull": true | ||
| 218 | } | ||
| 219 | }, | ||
| 220 | "indexes": { | ||
| 221 | "artifact_uidx": { | ||
| 222 | "name": "artifact_uidx", | ||
| 223 | "columns": [ | ||
| 224 | { | ||
| 225 | "expression": "tenant", | ||
| 226 | "isExpression": false, | ||
| 227 | "asc": true, | ||
| 228 | "nulls": "last" | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | "expression": "owner", | ||
| 232 | "isExpression": false, | ||
| 233 | "asc": true, | ||
| 234 | "nulls": "last" | ||
| 235 | }, | ||
| 236 | { | ||
| 237 | "expression": "subject", | ||
| 238 | "isExpression": false, | ||
| 239 | "asc": true, | ||
| 240 | "nulls": "last" | ||
| 241 | }, | ||
| 242 | { | ||
| 243 | "expression": "id", | ||
| 244 | "isExpression": false, | ||
| 245 | "asc": true, | ||
| 246 | "nulls": "last" | ||
| 247 | } | ||
| 248 | ], | ||
| 249 | "isUnique": true, | ||
| 250 | "concurrently": false, | ||
| 251 | "method": "btree", | ||
| 252 | "with": {} | ||
| 253 | } | ||
| 254 | }, | ||
| 255 | "foreignKeys": {}, | ||
| 256 | "compositePrimaryKeys": {}, | ||
| 257 | "uniqueConstraints": {}, | ||
| 258 | "policies": {}, | ||
| 259 | "checkConstraints": {}, | ||
| 260 | "isRLSEnabled": false | ||
| 261 | }, | ||
| 262 | "public.blob": { | ||
| 263 | "name": "blob", | ||
| 264 | "schema": "", | ||
| 265 | "columns": { | ||
| 266 | "namespace": { | ||
| 267 | "name": "namespace", | ||
| 268 | "type": "varchar(255)", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true | ||
| 271 | }, | ||
| 272 | "key": { | ||
| 273 | "name": "key", | ||
| 274 | "type": "varchar(255)", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": true | ||
| 277 | }, | ||
| 278 | "value": { | ||
| 279 | "name": "value", | ||
| 280 | "type": "text", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": true | ||
| 283 | }, | ||
| 284 | "row_id": { | ||
| 285 | "name": "row_id", | ||
| 286 | "type": "varchar(255)", | ||
| 287 | "primaryKey": true, | ||
| 288 | "notNull": true | ||
| 289 | }, | ||
| 290 | "id": { | ||
| 291 | "name": "id", | ||
| 292 | "type": "varchar(255)", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": true | ||
| 295 | } | ||
| 296 | }, | ||
| 297 | "indexes": { | ||
| 298 | "blob_id_uidx": { | ||
| 299 | "name": "blob_id_uidx", | ||
| 300 | "columns": [ | ||
| 301 | { | ||
| 302 | "expression": "id", | ||
| 303 | "isExpression": false, | ||
| 304 | "asc": true, | ||
| 305 | "nulls": "last" | ||
| 306 | } | ||
| 307 | ], | ||
| 308 | "isUnique": true, | ||
| 309 | "concurrently": false, | ||
| 310 | "method": "btree", | ||
| 311 | "with": {} | ||
| 312 | } | ||
| 313 | }, | ||
| 314 | "foreignKeys": {}, | ||
| 315 | "compositePrimaryKeys": {}, | ||
| 316 | "uniqueConstraints": {}, | ||
| 317 | "policies": {}, | ||
| 318 | "checkConstraints": {}, | ||
| 319 | "isRLSEnabled": false | ||
| 320 | }, | ||
| 321 | "public.connection": { | ||
| 322 | "name": "connection", | ||
| 323 | "schema": "", | ||
| 324 | "columns": { | ||
| 325 | "integration": { | ||
| 326 | "name": "integration", | ||
| 327 | "type": "varchar(255)", | ||
| 328 | "primaryKey": false, | ||
| 329 | "notNull": true | ||
| 330 | }, | ||
| 331 | "name": { | ||
| 332 | "name": "name", | ||
| 333 | "type": "varchar(255)", | ||
| 334 | "primaryKey": false, | ||
| 335 | "notNull": true | ||
| 336 | }, | ||
| 337 | "template": { | ||
| 338 | "name": "template", | ||
| 339 | "type": "text", | ||
| 340 | "primaryKey": false, | ||
| 341 | "notNull": true | ||
| 342 | }, | ||
| 343 | "provider": { | ||
| 344 | "name": "provider", | ||
| 345 | "type": "text", | ||
| 346 | "primaryKey": false, | ||
| 347 | "notNull": true | ||
| 348 | }, | ||
| 349 | "item_ids": { | ||
| 350 | "name": "item_ids", | ||
| 351 | "type": "json", | ||
| 352 | "primaryKey": false, | ||
| 353 | "notNull": true | ||
| 354 | }, | ||
| 355 | "identity_label": { | ||
| 356 | "name": "identity_label", | ||
| 357 | "type": "text", | ||
| 358 | "primaryKey": false, | ||
| 359 | "notNull": false | ||
| 360 | }, | ||
| 361 | "description": { | ||
| 362 | "name": "description", | ||
| 363 | "type": "text", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": false | ||
| 366 | }, | ||
| 367 | "last_health": { | ||
| 368 | "name": "last_health", | ||
| 369 | "type": "json", | ||
| 370 | "primaryKey": false, | ||
| 371 | "notNull": false | ||
| 372 | }, | ||
| 373 | "tools_synced_at": { | ||
| 374 | "name": "tools_synced_at", | ||
| 375 | "type": "bigint", | ||
| 376 | "primaryKey": false, | ||
| 377 | "notNull": false | ||
| 378 | }, | ||
| 379 | "oauth_client": { | ||
| 380 | "name": "oauth_client", | ||
| 381 | "type": "text", | ||
| 382 | "primaryKey": false, | ||
| 383 | "notNull": false | ||
| 384 | }, | ||
| 385 | "oauth_client_owner": { | ||
| 386 | "name": "oauth_client_owner", | ||
| 387 | "type": "text", | ||
| 388 | "primaryKey": false, | ||
| 389 | "notNull": false | ||
| 390 | }, | ||
| 391 | "refresh_item_id": { | ||
| 392 | "name": "refresh_item_id", | ||
| 393 | "type": "text", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": false | ||
| 396 | }, | ||
| 397 | "expires_at": { | ||
| 398 | "name": "expires_at", | ||
| 399 | "type": "bigint", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": false | ||
| 402 | }, | ||
| 403 | "oauth_scope": { | ||
| 404 | "name": "oauth_scope", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": false | ||
| 408 | }, | ||
| 409 | "oauth_token_url": { | ||
| 410 | "name": "oauth_token_url", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": false | ||
| 414 | }, | ||
| 415 | "provider_state": { | ||
| 416 | "name": "provider_state", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": false | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "updated_at": { | ||
| 428 | "name": "updated_at", | ||
| 429 | "type": "timestamp", | ||
| 430 | "primaryKey": false, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "row_id": { | ||
| 434 | "name": "row_id", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": true, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "tenant": { | ||
| 440 | "name": "tenant", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "owner": { | ||
| 446 | "name": "owner", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | }, | ||
| 451 | "subject": { | ||
| 452 | "name": "subject", | ||
| 453 | "type": "varchar(255)", | ||
| 454 | "primaryKey": false, | ||
| 455 | "notNull": true | ||
| 456 | } | ||
| 457 | }, | ||
| 458 | "indexes": { | ||
| 459 | "connection_uidx": { | ||
| 460 | "name": "connection_uidx", | ||
| 461 | "columns": [ | ||
| 462 | { | ||
| 463 | "expression": "tenant", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "owner", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "subject", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "integration", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.definition": { | ||
| 507 | "name": "definition", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "integration": { | ||
| 511 | "name": "integration", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "connection": { | ||
| 517 | "name": "connection", | ||
| 518 | "type": "varchar(255)", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "plugin_id": { | ||
| 523 | "name": "plugin_id", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": true | ||
| 527 | }, | ||
| 528 | "name": { | ||
| 529 | "name": "name", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": true | ||
| 533 | }, | ||
| 534 | "schema": { | ||
| 535 | "name": "schema", | ||
| 536 | "type": "json", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": true | ||
| 539 | }, | ||
| 540 | "created_at": { | ||
| 541 | "name": "created_at", | ||
| 542 | "type": "timestamp", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": true | ||
| 545 | }, | ||
| 546 | "row_id": { | ||
| 547 | "name": "row_id", | ||
| 548 | "type": "varchar(255)", | ||
| 549 | "primaryKey": true, | ||
| 550 | "notNull": true | ||
| 551 | }, | ||
| 552 | "tenant": { | ||
| 553 | "name": "tenant", | ||
| 554 | "type": "varchar(255)", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true | ||
| 557 | }, | ||
| 558 | "owner": { | ||
| 559 | "name": "owner", | ||
| 560 | "type": "varchar(255)", | ||
| 561 | "primaryKey": false, | ||
| 562 | "notNull": true | ||
| 563 | }, | ||
| 564 | "subject": { | ||
| 565 | "name": "subject", | ||
| 566 | "type": "varchar(255)", | ||
| 567 | "primaryKey": false, | ||
| 568 | "notNull": true | ||
| 569 | } | ||
| 570 | }, | ||
| 571 | "indexes": { | ||
| 572 | "definition_uidx": { | ||
| 573 | "name": "definition_uidx", | ||
| 574 | "columns": [ | ||
| 575 | { | ||
| 576 | "expression": "tenant", | ||
| 577 | "isExpression": false, | ||
| 578 | "asc": true, | ||
| 579 | "nulls": "last" | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | "expression": "owner", | ||
| 583 | "isExpression": false, | ||
| 584 | "asc": true, | ||
| 585 | "nulls": "last" | ||
| 586 | }, | ||
| 587 | { | ||
| 588 | "expression": "subject", | ||
| 589 | "isExpression": false, | ||
| 590 | "asc": true, | ||
| 591 | "nulls": "last" | ||
| 592 | }, | ||
| 593 | { | ||
| 594 | "expression": "integration", | ||
| 595 | "isExpression": false, | ||
| 596 | "asc": true, | ||
| 597 | "nulls": "last" | ||
| 598 | }, | ||
| 599 | { | ||
| 600 | "expression": "connection", | ||
| 601 | "isExpression": false, | ||
| 602 | "asc": true, | ||
| 603 | "nulls": "last" | ||
| 604 | }, | ||
| 605 | { | ||
| 606 | "expression": "name", | ||
| 607 | "isExpression": false, | ||
| 608 | "asc": true, | ||
| 609 | "nulls": "last" | ||
| 610 | } | ||
| 611 | ], | ||
| 612 | "isUnique": true, | ||
| 613 | "concurrently": false, | ||
| 614 | "method": "btree", | ||
| 615 | "with": {} | ||
| 616 | } | ||
| 617 | }, | ||
| 618 | "foreignKeys": {}, | ||
| 619 | "compositePrimaryKeys": {}, | ||
| 620 | "uniqueConstraints": {}, | ||
| 621 | "policies": {}, | ||
| 622 | "checkConstraints": {}, | ||
| 623 | "isRLSEnabled": false | ||
| 624 | }, | ||
| 625 | "public.integration": { | ||
| 626 | "name": "integration", | ||
| 627 | "schema": "", | ||
| 628 | "columns": { | ||
| 629 | "slug": { | ||
| 630 | "name": "slug", | ||
| 631 | "type": "varchar(255)", | ||
| 632 | "primaryKey": false, | ||
| 633 | "notNull": true | ||
| 634 | }, | ||
| 635 | "plugin_id": { | ||
| 636 | "name": "plugin_id", | ||
| 637 | "type": "text", | ||
| 638 | "primaryKey": false, | ||
| 639 | "notNull": true | ||
| 640 | }, | ||
| 641 | "name": { | ||
| 642 | "name": "name", | ||
| 643 | "type": "text", | ||
| 644 | "primaryKey": false, | ||
| 645 | "notNull": false | ||
| 646 | }, | ||
| 647 | "description": { | ||
| 648 | "name": "description", | ||
| 649 | "type": "text", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": false | ||
| 652 | }, | ||
| 653 | "config": { | ||
| 654 | "name": "config", | ||
| 655 | "type": "json", | ||
| 656 | "primaryKey": false, | ||
| 657 | "notNull": false | ||
| 658 | }, | ||
| 659 | "health_check": { | ||
| 660 | "name": "health_check", | ||
| 661 | "type": "json", | ||
| 662 | "primaryKey": false, | ||
| 663 | "notNull": false | ||
| 664 | }, | ||
| 665 | "config_revised_at": { | ||
| 666 | "name": "config_revised_at", | ||
| 667 | "type": "bigint", | ||
| 668 | "primaryKey": false, | ||
| 669 | "notNull": false | ||
| 670 | }, | ||
| 671 | "can_remove": { | ||
| 672 | "name": "can_remove", | ||
| 673 | "type": "boolean", | ||
| 674 | "primaryKey": false, | ||
| 675 | "notNull": true, | ||
| 676 | "default": true | ||
| 677 | }, | ||
| 678 | "can_refresh": { | ||
| 679 | "name": "can_refresh", | ||
| 680 | "type": "boolean", | ||
| 681 | "primaryKey": false, | ||
| 682 | "notNull": true, | ||
| 683 | "default": false | ||
| 684 | }, | ||
| 685 | "created_at": { | ||
| 686 | "name": "created_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "updated_at": { | ||
| 692 | "name": "updated_at", | ||
| 693 | "type": "timestamp", | ||
| 694 | "primaryKey": false, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "row_id": { | ||
| 698 | "name": "row_id", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": true, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "tenant": { | ||
| 704 | "name": "tenant", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | } | ||
| 709 | }, | ||
| 710 | "indexes": { | ||
| 711 | "integration_uidx": { | ||
| 712 | "name": "integration_uidx", | ||
| 713 | "columns": [ | ||
| 714 | { | ||
| 715 | "expression": "tenant", | ||
| 716 | "isExpression": false, | ||
| 717 | "asc": true, | ||
| 718 | "nulls": "last" | ||
| 719 | }, | ||
| 720 | { | ||
| 721 | "expression": "slug", | ||
| 722 | "isExpression": false, | ||
| 723 | "asc": true, | ||
| 724 | "nulls": "last" | ||
| 725 | } | ||
| 726 | ], | ||
| 727 | "isUnique": true, | ||
| 728 | "concurrently": false, | ||
| 729 | "method": "btree", | ||
| 730 | "with": {} | ||
| 731 | } | ||
| 732 | }, | ||
| 733 | "foreignKeys": {}, | ||
| 734 | "compositePrimaryKeys": {}, | ||
| 735 | "uniqueConstraints": {}, | ||
| 736 | "policies": {}, | ||
| 737 | "checkConstraints": {}, | ||
| 738 | "isRLSEnabled": false | ||
| 739 | }, | ||
| 740 | "public.oauth_client": { | ||
| 741 | "name": "oauth_client", | ||
| 742 | "schema": "", | ||
| 743 | "columns": { | ||
| 744 | "slug": { | ||
| 745 | "name": "slug", | ||
| 746 | "type": "varchar(255)", | ||
| 747 | "primaryKey": false, | ||
| 748 | "notNull": true | ||
| 749 | }, | ||
| 750 | "authorization_url": { | ||
| 751 | "name": "authorization_url", | ||
| 752 | "type": "text", | ||
| 753 | "primaryKey": false, | ||
| 754 | "notNull": true | ||
| 755 | }, | ||
| 756 | "token_url": { | ||
| 757 | "name": "token_url", | ||
| 758 | "type": "text", | ||
| 759 | "primaryKey": false, | ||
| 760 | "notNull": true | ||
| 761 | }, | ||
| 762 | "grant": { | ||
| 763 | "name": "grant", | ||
| 764 | "type": "text", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_id": { | ||
| 769 | "name": "client_id", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "client_secret_item_id": { | ||
| 775 | "name": "client_secret_item_id", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": false | ||
| 779 | }, | ||
| 780 | "resource": { | ||
| 781 | "name": "resource", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": false | ||
| 785 | }, | ||
| 786 | "origin_kind": { | ||
| 787 | "name": "origin_kind", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": false | ||
| 791 | }, | ||
| 792 | "origin_integration": { | ||
| 793 | "name": "origin_integration", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": false | ||
| 797 | }, | ||
| 798 | "origin_issuer": { | ||
| 799 | "name": "origin_issuer", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "origin_redirect_uri": { | ||
| 805 | "name": "origin_redirect_uri", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "created_at": { | ||
| 811 | "name": "created_at", | ||
| 812 | "type": "timestamp", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "row_id": { | ||
| 817 | "name": "row_id", | ||
| 818 | "type": "varchar(255)", | ||
| 819 | "primaryKey": true, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "tenant": { | ||
| 823 | "name": "tenant", | ||
| 824 | "type": "varchar(255)", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "owner": { | ||
| 829 | "name": "owner", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": false, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "subject": { | ||
| 835 | "name": "subject", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": false, | ||
| 838 | "notNull": true | ||
| 839 | } | ||
| 840 | }, | ||
| 841 | "indexes": { | ||
| 842 | "oauth_client_uidx": { | ||
| 843 | "name": "oauth_client_uidx", | ||
| 844 | "columns": [ | ||
| 845 | { | ||
| 846 | "expression": "tenant", | ||
| 847 | "isExpression": false, | ||
| 848 | "asc": true, | ||
| 849 | "nulls": "last" | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | "expression": "owner", | ||
| 853 | "isExpression": false, | ||
| 854 | "asc": true, | ||
| 855 | "nulls": "last" | ||
| 856 | }, | ||
| 857 | { | ||
| 858 | "expression": "subject", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | }, | ||
| 863 | { | ||
| 864 | "expression": "slug", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | } | ||
| 869 | ], | ||
| 870 | "isUnique": true, | ||
| 871 | "concurrently": false, | ||
| 872 | "method": "btree", | ||
| 873 | "with": {} | ||
| 874 | } | ||
| 875 | }, | ||
| 876 | "foreignKeys": {}, | ||
| 877 | "compositePrimaryKeys": {}, | ||
| 878 | "uniqueConstraints": {}, | ||
| 879 | "policies": {}, | ||
| 880 | "checkConstraints": {}, | ||
| 881 | "isRLSEnabled": false | ||
| 882 | }, | ||
| 883 | "public.oauth_session": { | ||
| 884 | "name": "oauth_session", | ||
| 885 | "schema": "", | ||
| 886 | "columns": { | ||
| 887 | "state": { | ||
| 888 | "name": "state", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "client_slug": { | ||
| 894 | "name": "client_slug", | ||
| 895 | "type": "text", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "integration": { | ||
| 900 | "name": "integration", | ||
| 901 | "type": "text", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "name": { | ||
| 906 | "name": "name", | ||
| 907 | "type": "text", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "template": { | ||
| 912 | "name": "template", | ||
| 913 | "type": "text", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "redirect_url": { | ||
| 918 | "name": "redirect_url", | ||
| 919 | "type": "text", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "pkce_verifier": { | ||
| 924 | "name": "pkce_verifier", | ||
| 925 | "type": "text", | ||
| 926 | "primaryKey": false, | ||
| 927 | "notNull": false | ||
| 928 | }, | ||
| 929 | "identity_label": { | ||
| 930 | "name": "identity_label", | ||
| 931 | "type": "text", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": false | ||
| 934 | }, | ||
| 935 | "payload": { | ||
| 936 | "name": "payload", | ||
| 937 | "type": "json", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "expires_at": { | ||
| 942 | "name": "expires_at", | ||
| 943 | "type": "bigint", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | }, | ||
| 947 | "created_at": { | ||
| 948 | "name": "created_at", | ||
| 949 | "type": "timestamp", | ||
| 950 | "primaryKey": false, | ||
| 951 | "notNull": true | ||
| 952 | }, | ||
| 953 | "row_id": { | ||
| 954 | "name": "row_id", | ||
| 955 | "type": "varchar(255)", | ||
| 956 | "primaryKey": true, | ||
| 957 | "notNull": true | ||
| 958 | }, | ||
| 959 | "tenant": { | ||
| 960 | "name": "tenant", | ||
| 961 | "type": "varchar(255)", | ||
| 962 | "primaryKey": false, | ||
| 963 | "notNull": true | ||
| 964 | }, | ||
| 965 | "owner": { | ||
| 966 | "name": "owner", | ||
| 967 | "type": "varchar(255)", | ||
| 968 | "primaryKey": false, | ||
| 969 | "notNull": true | ||
| 970 | }, | ||
| 971 | "subject": { | ||
| 972 | "name": "subject", | ||
| 973 | "type": "varchar(255)", | ||
| 974 | "primaryKey": false, | ||
| 975 | "notNull": true | ||
| 976 | } | ||
| 977 | }, | ||
| 978 | "indexes": { | ||
| 979 | "oauth_session_uidx": { | ||
| 980 | "name": "oauth_session_uidx", | ||
| 981 | "columns": [ | ||
| 982 | { | ||
| 983 | "expression": "tenant", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | }, | ||
| 988 | { | ||
| 989 | "expression": "state", | ||
| 990 | "isExpression": false, | ||
| 991 | "asc": true, | ||
| 992 | "nulls": "last" | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": true, | ||
| 996 | "concurrently": false, | ||
| 997 | "method": "btree", | ||
| 998 | "with": {} | ||
| 999 | } | ||
| 1000 | }, | ||
| 1001 | "foreignKeys": {}, | ||
| 1002 | "compositePrimaryKeys": {}, | ||
| 1003 | "uniqueConstraints": {}, | ||
| 1004 | "policies": {}, | ||
| 1005 | "checkConstraints": {}, | ||
| 1006 | "isRLSEnabled": false | ||
| 1007 | }, | ||
| 1008 | "public.plugin_storage": { | ||
| 1009 | "name": "plugin_storage", | ||
| 1010 | "schema": "", | ||
| 1011 | "columns": { | ||
| 1012 | "plugin_id": { | ||
| 1013 | "name": "plugin_id", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true | ||
| 1017 | }, | ||
| 1018 | "collection": { | ||
| 1019 | "name": "collection", | ||
| 1020 | "type": "varchar(255)", | ||
| 1021 | "primaryKey": false, | ||
| 1022 | "notNull": true | ||
| 1023 | }, | ||
| 1024 | "key": { | ||
| 1025 | "name": "key", | ||
| 1026 | "type": "varchar(255)", | ||
| 1027 | "primaryKey": false, | ||
| 1028 | "notNull": true | ||
| 1029 | }, | ||
| 1030 | "data": { | ||
| 1031 | "name": "data", | ||
| 1032 | "type": "json", | ||
| 1033 | "primaryKey": false, | ||
| 1034 | "notNull": true | ||
| 1035 | }, | ||
| 1036 | "created_at": { | ||
| 1037 | "name": "created_at", | ||
| 1038 | "type": "timestamp", | ||
| 1039 | "primaryKey": false, | ||
| 1040 | "notNull": true | ||
| 1041 | }, | ||
| 1042 | "updated_at": { | ||
| 1043 | "name": "updated_at", | ||
| 1044 | "type": "timestamp", | ||
| 1045 | "primaryKey": false, | ||
| 1046 | "notNull": true | ||
| 1047 | }, | ||
| 1048 | "row_id": { | ||
| 1049 | "name": "row_id", | ||
| 1050 | "type": "varchar(255)", | ||
| 1051 | "primaryKey": true, | ||
| 1052 | "notNull": true | ||
| 1053 | }, | ||
| 1054 | "tenant": { | ||
| 1055 | "name": "tenant", | ||
| 1056 | "type": "varchar(255)", | ||
| 1057 | "primaryKey": false, | ||
| 1058 | "notNull": true | ||
| 1059 | }, | ||
| 1060 | "owner": { | ||
| 1061 | "name": "owner", | ||
| 1062 | "type": "varchar(255)", | ||
| 1063 | "primaryKey": false, | ||
| 1064 | "notNull": true | ||
| 1065 | }, | ||
| 1066 | "subject": { | ||
| 1067 | "name": "subject", | ||
| 1068 | "type": "varchar(255)", | ||
| 1069 | "primaryKey": false, | ||
| 1070 | "notNull": true | ||
| 1071 | } | ||
| 1072 | }, | ||
| 1073 | "indexes": { | ||
| 1074 | "plugin_storage_uidx": { | ||
| 1075 | "name": "plugin_storage_uidx", | ||
| 1076 | "columns": [ | ||
| 1077 | { | ||
| 1078 | "expression": "tenant", | ||
| 1079 | "isExpression": false, | ||
| 1080 | "asc": true, | ||
| 1081 | "nulls": "last" | ||
| 1082 | }, | ||
| 1083 | { | ||
| 1084 | "expression": "owner", | ||
| 1085 | "isExpression": false, | ||
| 1086 | "asc": true, | ||
| 1087 | "nulls": "last" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "expression": "subject", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nulls": "last" | ||
| 1094 | }, | ||
| 1095 | { | ||
| 1096 | "expression": "plugin_id", | ||
| 1097 | "isExpression": false, | ||
| 1098 | "asc": true, | ||
| 1099 | "nulls": "last" | ||
| 1100 | }, | ||
| 1101 | { | ||
| 1102 | "expression": "collection", | ||
| 1103 | "isExpression": false, | ||
| 1104 | "asc": true, | ||
| 1105 | "nulls": "last" | ||
| 1106 | }, | ||
| 1107 | { | ||
| 1108 | "expression": "key", | ||
| 1109 | "isExpression": false, | ||
| 1110 | "asc": true, | ||
| 1111 | "nulls": "last" | ||
| 1112 | } | ||
| 1113 | ], | ||
| 1114 | "isUnique": true, | ||
| 1115 | "concurrently": false, | ||
| 1116 | "method": "btree", | ||
| 1117 | "with": {} | ||
| 1118 | } | ||
| 1119 | }, | ||
| 1120 | "foreignKeys": {}, | ||
| 1121 | "compositePrimaryKeys": {}, | ||
| 1122 | "uniqueConstraints": {}, | ||
| 1123 | "policies": {}, | ||
| 1124 | "checkConstraints": {}, | ||
| 1125 | "isRLSEnabled": false | ||
| 1126 | }, | ||
| 1127 | "public.private_executor_cloud_settings": { | ||
| 1128 | "name": "private_executor_cloud_settings", | ||
| 1129 | "schema": "", | ||
| 1130 | "columns": { | ||
| 1131 | "id": { | ||
| 1132 | "name": "id", | ||
| 1133 | "type": "varchar(255)", | ||
| 1134 | "primaryKey": true, | ||
| 1135 | "notNull": true | ||
| 1136 | }, | ||
| 1137 | "version": { | ||
| 1138 | "name": "version", | ||
| 1139 | "type": "varchar(255)", | ||
| 1140 | "primaryKey": false, | ||
| 1141 | "notNull": true, | ||
| 1142 | "default": "'1.0.0'" | ||
| 1143 | } | ||
| 1144 | }, | ||
| 1145 | "indexes": {}, | ||
| 1146 | "foreignKeys": {}, | ||
| 1147 | "compositePrimaryKeys": {}, | ||
| 1148 | "uniqueConstraints": {}, | ||
| 1149 | "policies": {}, | ||
| 1150 | "checkConstraints": {}, | ||
| 1151 | "isRLSEnabled": false | ||
| 1152 | }, | ||
| 1153 | "public.subject": { | ||
| 1154 | "name": "subject", | ||
| 1155 | "schema": "", | ||
| 1156 | "columns": { | ||
| 1157 | "external_id": { | ||
| 1158 | "name": "external_id", | ||
| 1159 | "type": "varchar(255)", | ||
| 1160 | "primaryKey": false, | ||
| 1161 | "notNull": true | ||
| 1162 | }, | ||
| 1163 | "created_at": { | ||
| 1164 | "name": "created_at", | ||
| 1165 | "type": "timestamp", | ||
| 1166 | "primaryKey": false, | ||
| 1167 | "notNull": true | ||
| 1168 | }, | ||
| 1169 | "last_seen_at": { | ||
| 1170 | "name": "last_seen_at", | ||
| 1171 | "type": "bigint", | ||
| 1172 | "primaryKey": false, | ||
| 1173 | "notNull": false | ||
| 1174 | }, | ||
| 1175 | "status": { | ||
| 1176 | "name": "status", | ||
| 1177 | "type": "text", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": false | ||
| 1180 | }, | ||
| 1181 | "row_id": { | ||
| 1182 | "name": "row_id", | ||
| 1183 | "type": "varchar(255)", | ||
| 1184 | "primaryKey": true, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "tenant": { | ||
| 1188 | "name": "tenant", | ||
| 1189 | "type": "varchar(255)", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | } | ||
| 1193 | }, | ||
| 1194 | "indexes": { | ||
| 1195 | "subject_uidx": { | ||
| 1196 | "name": "subject_uidx", | ||
| 1197 | "columns": [ | ||
| 1198 | { | ||
| 1199 | "expression": "tenant", | ||
| 1200 | "isExpression": false, | ||
| 1201 | "asc": true, | ||
| 1202 | "nulls": "last" | ||
| 1203 | }, | ||
| 1204 | { | ||
| 1205 | "expression": "external_id", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nulls": "last" | ||
| 1209 | } | ||
| 1210 | ], | ||
| 1211 | "isUnique": true, | ||
| 1212 | "concurrently": false, | ||
| 1213 | "method": "btree", | ||
| 1214 | "with": {} | ||
| 1215 | } | ||
| 1216 | }, | ||
| 1217 | "foreignKeys": {}, | ||
| 1218 | "compositePrimaryKeys": {}, | ||
| 1219 | "uniqueConstraints": {}, | ||
| 1220 | "policies": {}, | ||
| 1221 | "checkConstraints": {}, | ||
| 1222 | "isRLSEnabled": false | ||
| 1223 | }, | ||
| 1224 | "public.tool": { | ||
| 1225 | "name": "tool", | ||
| 1226 | "schema": "", | ||
| 1227 | "columns": { | ||
| 1228 | "integration": { | ||
| 1229 | "name": "integration", | ||
| 1230 | "type": "varchar(255)", | ||
| 1231 | "primaryKey": false, | ||
| 1232 | "notNull": true | ||
| 1233 | }, | ||
| 1234 | "connection": { | ||
| 1235 | "name": "connection", | ||
| 1236 | "type": "varchar(255)", | ||
| 1237 | "primaryKey": false, | ||
| 1238 | "notNull": true | ||
| 1239 | }, | ||
| 1240 | "plugin_id": { | ||
| 1241 | "name": "plugin_id", | ||
| 1242 | "type": "text", | ||
| 1243 | "primaryKey": false, | ||
| 1244 | "notNull": true | ||
| 1245 | }, | ||
| 1246 | "name": { | ||
| 1247 | "name": "name", | ||
| 1248 | "type": "varchar(255)", | ||
| 1249 | "primaryKey": false, | ||
| 1250 | "notNull": true | ||
| 1251 | }, | ||
| 1252 | "description": { | ||
| 1253 | "name": "description", | ||
| 1254 | "type": "text", | ||
| 1255 | "primaryKey": false, | ||
| 1256 | "notNull": true | ||
| 1257 | }, | ||
| 1258 | "input_schema": { | ||
| 1259 | "name": "input_schema", | ||
| 1260 | "type": "json", | ||
| 1261 | "primaryKey": false, | ||
| 1262 | "notNull": false | ||
| 1263 | }, | ||
| 1264 | "output_schema": { | ||
| 1265 | "name": "output_schema", | ||
| 1266 | "type": "json", | ||
| 1267 | "primaryKey": false, | ||
| 1268 | "notNull": false | ||
| 1269 | }, | ||
| 1270 | "annotations": { | ||
| 1271 | "name": "annotations", | ||
| 1272 | "type": "json", | ||
| 1273 | "primaryKey": false, | ||
| 1274 | "notNull": false | ||
| 1275 | }, | ||
| 1276 | "created_at": { | ||
| 1277 | "name": "created_at", | ||
| 1278 | "type": "timestamp", | ||
| 1279 | "primaryKey": false, | ||
| 1280 | "notNull": true | ||
| 1281 | }, | ||
| 1282 | "updated_at": { | ||
| 1283 | "name": "updated_at", | ||
| 1284 | "type": "timestamp", | ||
| 1285 | "primaryKey": false, | ||
| 1286 | "notNull": true | ||
| 1287 | }, | ||
| 1288 | "row_id": { | ||
| 1289 | "name": "row_id", | ||
| 1290 | "type": "varchar(255)", | ||
| 1291 | "primaryKey": true, | ||
| 1292 | "notNull": true | ||
| 1293 | }, | ||
| 1294 | "tenant": { | ||
| 1295 | "name": "tenant", | ||
| 1296 | "type": "varchar(255)", | ||
| 1297 | "primaryKey": false, | ||
| 1298 | "notNull": true | ||
| 1299 | }, | ||
| 1300 | "owner": { | ||
| 1301 | "name": "owner", | ||
| 1302 | "type": "varchar(255)", | ||
| 1303 | "primaryKey": false, | ||
| 1304 | "notNull": true | ||
| 1305 | }, | ||
| 1306 | "subject": { | ||
| 1307 | "name": "subject", | ||
| 1308 | "type": "varchar(255)", | ||
| 1309 | "primaryKey": false, | ||
| 1310 | "notNull": true | ||
| 1311 | } | ||
| 1312 | }, | ||
| 1313 | "indexes": { | ||
| 1314 | "tool_uidx": { | ||
| 1315 | "name": "tool_uidx", | ||
| 1316 | "columns": [ | ||
| 1317 | { | ||
| 1318 | "expression": "tenant", | ||
| 1319 | "isExpression": false, | ||
| 1320 | "asc": true, | ||
| 1321 | "nulls": "last" | ||
| 1322 | }, | ||
| 1323 | { | ||
| 1324 | "expression": "owner", | ||
| 1325 | "isExpression": false, | ||
| 1326 | "asc": true, | ||
| 1327 | "nulls": "last" | ||
| 1328 | }, | ||
| 1329 | { | ||
| 1330 | "expression": "subject", | ||
| 1331 | "isExpression": false, | ||
| 1332 | "asc": true, | ||
| 1333 | "nulls": "last" | ||
| 1334 | }, | ||
| 1335 | { | ||
| 1336 | "expression": "integration", | ||
| 1337 | "isExpression": false, | ||
| 1338 | "asc": true, | ||
| 1339 | "nulls": "last" | ||
| 1340 | }, | ||
| 1341 | { | ||
| 1342 | "expression": "connection", | ||
| 1343 | "isExpression": false, | ||
| 1344 | "asc": true, | ||
| 1345 | "nulls": "last" | ||
| 1346 | }, | ||
| 1347 | { | ||
| 1348 | "expression": "name", | ||
| 1349 | "isExpression": false, | ||
| 1350 | "asc": true, | ||
| 1351 | "nulls": "last" | ||
| 1352 | } | ||
| 1353 | ], | ||
| 1354 | "isUnique": true, | ||
| 1355 | "concurrently": false, | ||
| 1356 | "method": "btree", | ||
| 1357 | "with": {} | ||
| 1358 | } | ||
| 1359 | }, | ||
| 1360 | "foreignKeys": {}, | ||
| 1361 | "compositePrimaryKeys": {}, | ||
| 1362 | "uniqueConstraints": {}, | ||
| 1363 | "policies": {}, | ||
| 1364 | "checkConstraints": {}, | ||
| 1365 | "isRLSEnabled": false | ||
| 1366 | }, | ||
| 1367 | "public.tool_policy": { | ||
| 1368 | "name": "tool_policy", | ||
| 1369 | "schema": "", | ||
| 1370 | "columns": { | ||
| 1371 | "id": { | ||
| 1372 | "name": "id", | ||
| 1373 | "type": "varchar(255)", | ||
| 1374 | "primaryKey": false, | ||
| 1375 | "notNull": true | ||
| 1376 | }, | ||
| 1377 | "pattern": { | ||
| 1378 | "name": "pattern", | ||
| 1379 | "type": "text", | ||
| 1380 | "primaryKey": false, | ||
| 1381 | "notNull": true | ||
| 1382 | }, | ||
| 1383 | "action": { | ||
| 1384 | "name": "action", | ||
| 1385 | "type": "text", | ||
| 1386 | "primaryKey": false, | ||
| 1387 | "notNull": true | ||
| 1388 | }, | ||
| 1389 | "position": { | ||
| 1390 | "name": "position", | ||
| 1391 | "type": "text", | ||
| 1392 | "primaryKey": false, | ||
| 1393 | "notNull": true | ||
| 1394 | }, | ||
| 1395 | "created_at": { | ||
| 1396 | "name": "created_at", | ||
| 1397 | "type": "timestamp", | ||
| 1398 | "primaryKey": false, | ||
| 1399 | "notNull": true | ||
| 1400 | }, | ||
| 1401 | "updated_at": { | ||
| 1402 | "name": "updated_at", | ||
| 1403 | "type": "timestamp", | ||
| 1404 | "primaryKey": false, | ||
| 1405 | "notNull": true | ||
| 1406 | }, | ||
| 1407 | "row_id": { | ||
| 1408 | "name": "row_id", | ||
| 1409 | "type": "varchar(255)", | ||
| 1410 | "primaryKey": true, | ||
| 1411 | "notNull": true | ||
| 1412 | }, | ||
| 1413 | "tenant": { | ||
| 1414 | "name": "tenant", | ||
| 1415 | "type": "varchar(255)", | ||
| 1416 | "primaryKey": false, | ||
| 1417 | "notNull": true | ||
| 1418 | }, | ||
| 1419 | "owner": { | ||
| 1420 | "name": "owner", | ||
| 1421 | "type": "varchar(255)", | ||
| 1422 | "primaryKey": false, | ||
| 1423 | "notNull": true | ||
| 1424 | }, | ||
| 1425 | "subject": { | ||
| 1426 | "name": "subject", | ||
| 1427 | "type": "varchar(255)", | ||
| 1428 | "primaryKey": false, | ||
| 1429 | "notNull": true | ||
| 1430 | } | ||
| 1431 | }, | ||
| 1432 | "indexes": { | ||
| 1433 | "tool_policy_uidx": { | ||
| 1434 | "name": "tool_policy_uidx", | ||
| 1435 | "columns": [ | ||
| 1436 | { | ||
| 1437 | "expression": "tenant", | ||
| 1438 | "isExpression": false, | ||
| 1439 | "asc": true, | ||
| 1440 | "nulls": "last" | ||
| 1441 | }, | ||
| 1442 | { | ||
| 1443 | "expression": "owner", | ||
| 1444 | "isExpression": false, | ||
| 1445 | "asc": true, | ||
| 1446 | "nulls": "last" | ||
| 1447 | }, | ||
| 1448 | { | ||
| 1449 | "expression": "subject", | ||
| 1450 | "isExpression": false, | ||
| 1451 | "asc": true, | ||
| 1452 | "nulls": "last" | ||
| 1453 | }, | ||
| 1454 | { | ||
| 1455 | "expression": "id", | ||
| 1456 | "isExpression": false, | ||
| 1457 | "asc": true, | ||
| 1458 | "nulls": "last" | ||
| 1459 | } | ||
| 1460 | ], | ||
| 1461 | "isUnique": true, | ||
| 1462 | "concurrently": false, | ||
| 1463 | "method": "btree", | ||
| 1464 | "with": {} | ||
| 1465 | } | ||
| 1466 | }, | ||
| 1467 | "foreignKeys": {}, | ||
| 1468 | "compositePrimaryKeys": {}, | ||
| 1469 | "uniqueConstraints": {}, | ||
| 1470 | "policies": {}, | ||
| 1471 | "checkConstraints": {}, | ||
| 1472 | "isRLSEnabled": false | ||
| 1473 | } | ||
| 1474 | }, | ||
| 1475 | "enums": {}, | ||
| 1476 | "schemas": {}, | ||
| 1477 | "sequences": {}, | ||
| 1478 | "roles": {}, | ||
| 1479 | "policies": {}, | ||
| 1480 | "views": {}, | ||
| 1481 | "_meta": { | ||
| 1482 | "columns": {}, | ||
| 1483 | "schemas": {}, | ||
| 1484 | "tables": {} | ||
| 1485 | } | ||
| 1486 | } | ||
| @@ -71,6 +71,48 @@ · 70 unmodified lines | |||
| 71 | "when": 1783025157233, | 71 | "when": 1783025157233, |
| 72 | "tag": "0009_true_lake", | 72 | "tag": "0009_true_lake", |
| 73 | "breakpoints": true | 73 | "breakpoints": true |
| 74 | }, | ||
| 75 | { | ||
| 76 | "idx": 10, | ||
| 77 | "version": "7", | ||
| 78 | "when": 1783660669921, | ||
| 79 | "tag": "0010_ndjson_output_arrays", | ||
| 80 | "breakpoints": true | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | "idx": 11, | ||
| 84 | "version": "7", | ||
| 85 | "when": 1784581232732, | ||
| 86 | "tag": "0011_tense_caretaker", | ||
| 87 | "breakpoints": true | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | "idx": 12, | ||
| 91 | "version": "7", | ||
| 92 | "when": 1785193313601, | ||
| 93 | "tag": "0012_woozy_shiva", | ||
| 94 | "breakpoints": true | ||
| 95 | }, | ||
| 96 | { | ||
| 97 | "idx": 13, | ||
| 98 | "version": "7", | ||
| 99 | "when": 1785354087138, | ||
| 100 | "tag": "0013_curly_rocket_racer", | ||
| 101 | "breakpoints": true | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | "idx": 14, | ||
| 105 | "version": "7", | ||
| 106 | "when": 1785355312973, | ||
| 107 | "tag": "0014_new_karma", | ||
| 108 | "breakpoints": true | ||
| 109 | }, | ||
| 110 | { | ||
| 111 | "idx": 15, | ||
| 112 | "version": "7", | ||
| 113 | "when": 1785355354955, | ||
| 114 | "tag": "0015_equal_the_leader", | ||
| 115 | "breakpoints": true | ||
| 74 | } | 116 | } |
| 75 | ] | 117 | ] |
| 76 | } | 118 | } |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | googleCatalog, |
| 5 | googleDiscoveryAdapter, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/google"; | ||
| 7 | import { | ||
| 8 | microsoftCatalog, | ||
| 9 | microsoftGraphAdapter, | ||
| 10 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 11 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 12 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; | 13 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +48,15 @@ interface CloudPluginDeps { · 34 unmodified lines | |||
| 42 | * falls back to the credential-driven default. */ | 48 | * falls back to the credential-driven default. */ |
| 43 | readonly workosVaultClient?: WorkOSVaultClient; | 49 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | readonly activeToolkitSlug?: string; | 50 | readonly activeToolkitSlug?: string; |
| 45 | /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets | ||
| 46 | * `microsoft.addGraph` point at a loopback emulator instead of the pinned | ||
| 47 | * Microsoft Graph URLs. Off by default; production leaves it unset. */ | ||
| 48 | readonly allowLocalNetwork?: boolean; | ||
| 49 | } | 51 | } |
| 50 | 52 | ||
| 51 | export default defineExecutorConfig({ | 53 | export default defineExecutorConfig({ |
| 52 | plugins: ({ | 54 | plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => |
| 53 | workosCredentials, | ||
| 54 | workosVaultClient, | ||
| 55 | activeToolkitSlug, | ||
| 56 | allowLocalNetwork, | ||
| 57 | }: CloudPluginDeps = {}) => | ||
| 58 | [ | 55 | [ |
| 59 | openApiHttpPlugin(), | 56 | openApiHttpPlugin({ |
| 60 | googleHttpPlugin(), | 57 | presets: [...googleCatalog, ...microsoftCatalog], |
| 61 | microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | 58 | specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], |
| 59 | }), | ||
| 62 | mcpHttpPlugin({ | 60 | mcpHttpPlugin({ |
| 63 | dangerouslyAllowStdioMCP: false, | 61 | dangerouslyAllowStdioMCP: false, |
| 64 | }), | 62 | }), |
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/cloud", | 2 | "name": "@executor-js/cloud", |
| 3 | "version": "1.4.47", | 3 | "version": "1.4.63", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -30,7 +30,10 @@ · 23 unmodified lines | |||
| 30 | "typecheck:slow": "tsc --noEmit", | 30 | "typecheck:slow": "tsc --noEmit", |
| 31 | "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", | 31 | "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", |
| 32 | "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", | 32 | "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", |
| 33 | "routes:gen": "bun scripts/gen-routes.ts" | 33 | "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", |
| 34 | "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", | ||
| 35 | "routes:gen": "bun scripts/gen-routes.ts", | ||
| 36 | "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" | ||
| 34 | }, | 37 | }, |
| 35 | "dependencies": { | 38 | "dependencies": { |
| 36 | "@cloudflare/vite-plugin": "^1.31.1", | 39 | "@cloudflare/vite-plugin": "^1.31.1", |
| @@ -41,10 +44,9 @@ · 4 unmodified lines | |||
| 41 | "@executor-js/execution": "workspace:*", | 44 | "@executor-js/execution": "workspace:*", |
| 42 | "@executor-js/fumadb": "workspace:*", | 45 | "@executor-js/fumadb": "workspace:*", |
| 43 | "@executor-js/host-mcp": "workspace:*", | 46 | "@executor-js/host-mcp": "workspace:*", |
| 44 | "@executor-js/plugin-google": "workspace:*", | 47 | "@executor-js/mcp-apps-shell": "workspace:*", |
| 45 | "@executor-js/plugin-graphql": "workspace:*", | 48 | "@executor-js/plugin-graphql": "workspace:*", |
| 46 | "@executor-js/plugin-mcp": "workspace:*", | 49 | "@executor-js/plugin-mcp": "workspace:*", |
| 47 | "@executor-js/plugin-microsoft": "workspace:*", | ||
| 48 | "@executor-js/plugin-openapi": "workspace:*", | 50 | "@executor-js/plugin-openapi": "workspace:*", |
| 49 | "@executor-js/plugin-toolkits": "workspace:*", | 51 | "@executor-js/plugin-toolkits": "workspace:*", |
| 50 | "@executor-js/plugin-workos-vault": "workspace:*", | 52 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -53,6 +55,7 @@ · 2 unmodified lines | |||
| 53 | "@executor-js/runtime-quickjs": "workspace:*", | 55 | "@executor-js/runtime-quickjs": "workspace:*", |
| 54 | "@executor-js/sdk": "workspace:*", | 56 | "@executor-js/sdk": "workspace:*", |
| 55 | "@executor-js/vite-plugin": "workspace:*", | 57 | "@executor-js/vite-plugin": "workspace:*", |
| 58 | "@jitl/quickjs-wasmfile-release-sync": "catalog:", | ||
| 56 | "@modelcontextprotocol/sdk": "^1.29.0", | 59 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 57 | "@opentelemetry/api": "~1.9.0", | 60 | "@opentelemetry/api": "~1.9.0", |
| 58 | "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", | 61 | "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", |
| @@ -70,9 +73,11 @@ · 11 unmodified lines | |||
| 70 | "autumn-js": "^1.2.8", | 73 | "autumn-js": "^1.2.8", |
| 71 | "drizzle-orm": "catalog:", | 74 | "drizzle-orm": "catalog:", |
| 72 | "effect": "catalog:", | 75 | "effect": "catalog:", |
| 76 | "iron-webcrypto": "2.0.0", | ||
| 73 | "jose": "^5.6.3", | 77 | "jose": "^5.6.3", |
| 74 | "postgres": "^3.4.9", | 78 | "postgres": "^3.4.9", |
| 75 | "posthog-js": "^1.372.5", | 79 | "posthog-js": "^1.372.5", |
| 80 | "quickjs-emscripten-core": "0.31.0", | ||
| 76 | "react": "catalog:", | 81 | "react": "catalog:", |
| 77 | "react-dom": "catalog:", | 82 | "react-dom": "catalog:", |
| 78 | "sonner": "^2.0.7" | 83 | "sonner": "^2.0.7" |
| @@ -0,0 +1,87 @@ | |||
| 1 | /* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ | ||
| 2 | // --------------------------------------------------------------------------- | ||
| 3 | // One-off data backfill: mint `subject` rows for principals that predate the | ||
| 4 | // subject table (added by migration 0012, written only on sightings from then | ||
| 5 | // on). | ||
| 6 | // | ||
| 7 | // bun run db:backfill-subjects:prod # op run --env-file=.env.production | ||
| 8 | // bun run db:backfill-subjects:dev # against the local PGlite dev db | ||
| 9 | // | ||
| 10 | // Source of truth is the `connection` table: before 0012, "which users exist | ||
| 11 | // under this tenant" was answerable only through connection rows, so a distinct | ||
| 12 | // (tenant, subject) there — excluding the org sentinel '' — is exactly the set | ||
| 13 | // of users the admin console under-reports until their next request. | ||
| 14 | // | ||
| 15 | // created_at ← the tenant+subject's oldest connection, the closest honest | ||
| 16 | // answer to "when did this user first appear". | ||
| 17 | // last_seen_at ← the tenant+subject's newest connection `updated_at`, the | ||
| 18 | // latest activity storage can attest to. Rendering every | ||
| 19 | // pre-0012 user as "Never" was literally true of the sighting | ||
| 20 | // column but reads as "inactive user" to an operator, which | ||
| 21 | // is the wrong answer for a tenant full of active people. The | ||
| 22 | // first real sighting overwrites this with request truth. | ||
| 23 | // | ||
| 24 | // Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique | ||
| 25 | // index leaves rows the runtime has since minted (or a prior run inserted) | ||
| 26 | // untouched, and the last_seen_at repair only fills NULLs — so re-running is | ||
| 27 | // safe and never rewinds a live row's fields. | ||
| 28 | // Pass --dry-run to print the plan without writing. | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | import postgres from "postgres"; | ||
| 32 | import { createId } from "@executor-js/fumadb/cuid"; | ||
| 33 | |||
| 34 | const dryRun = process.argv.includes("--dry-run"); | ||
| 35 | |||
| 36 | const connectionString = process.env.DATABASE_URL; | ||
| 37 | if (!connectionString) { | ||
| 38 | console.error("DATABASE_URL is not set"); | ||
| 39 | process.exit(1); | ||
| 40 | } | ||
| 41 | |||
| 42 | const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); | ||
| 43 | |||
| 44 | const rows = await sql< | ||
| 45 | { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[] | ||
| 46 | >` | ||
| 47 | SELECT tenant, subject, | ||
| 48 | MIN(created_at) AS first_connection_at, | ||
| 49 | MAX(updated_at) AS last_activity_at | ||
| 50 | FROM connection | ||
| 51 | WHERE subject <> '' | ||
| 52 | GROUP BY tenant, subject | ||
| 53 | ORDER BY tenant, subject | ||
| 54 | `; | ||
| 55 | console.log(`${rows.length} (tenant, subject) pair(s) with connections`); | ||
| 56 | |||
| 57 | let inserted = 0; | ||
| 58 | let repaired = 0; | ||
| 59 | for (const row of rows) { | ||
| 60 | console.log( | ||
| 61 | `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`, | ||
| 62 | ); | ||
| 63 | if (dryRun) continue; | ||
| 64 | const lastSeenMs = row.last_activity_at.getTime(); | ||
| 65 | const result = await sql` | ||
| 66 | INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) | ||
| 67 | VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL) | ||
| 68 | ON CONFLICT (tenant, external_id) DO NOTHING | ||
| 69 | `; | ||
| 70 | inserted += result.count; | ||
| 71 | if (result.count > 0) continue; | ||
| 72 | // The row predates this run but may still carry NULL from a run of the | ||
| 73 | // pre-repair version of this script. Fill it in; a sighted row keeps its | ||
| 74 | // request-truth timestamp. | ||
| 75 | const repair = await sql` | ||
| 76 | UPDATE subject SET last_seen_at = ${lastSeenMs} | ||
| 77 | WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL | ||
| 78 | `; | ||
| 79 | repaired += repair.count; | ||
| 80 | } | ||
| 81 | |||
| 82 | console.log( | ||
| 83 | dryRun | ||
| 84 | ? `dry run — would insert up to ${rows.length} row(s)` | ||
| 85 | : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`, | ||
| 86 | ); | ||
| 87 | await sql.end(); | ||
| @@ -9,7 +9,7 @@ · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { spawnSync } from "node:child_process"; | 10 | import { spawnSync } from "node:child_process"; |
| 11 | import { randomBytes } from "node:crypto"; | 11 | import { randomBytes } from "node:crypto"; |
| 12 | import { rmSync } from "node:fs"; | 12 | import { existsSync, readFileSync, rmSync } from "node:fs"; |
| 13 | 13 | ||
| 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { | 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { |
| 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); | 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); |
| @@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines | |||
| 32 | process.exit(result.status ?? 1); | 32 | process.exit(result.status ?? 1); |
| 33 | } | 33 | } |
| 34 | } | 34 | } |
| 35 | |||
| 36 | // The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`) | ||
| 37 | // by fetching this stable-named asset through the ASSETS binding — it has no | ||
| 38 | // filesystem to read the shell from. A build that failed to emit it would | ||
| 39 | // deploy a Worker whose artifact resource reads all fail, so make the miss a | ||
| 40 | // build failure here instead of a production incident. Same shape as the | ||
| 41 | // worker-bundler asset assertion from #1378. | ||
| 42 | const shellAsset = new URL( | ||
| 43 | "../dist/client/assets/executor-mcp-apps-shell-stable.html", | ||
| 44 | import.meta.url, | ||
| 45 | ); | ||
| 46 | if (!existsSync(shellAsset)) { | ||
| 47 | console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); | ||
| 48 | process.exit(1); | ||
| 49 | } | ||
| 50 | if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { | ||
| 51 | console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); | ||
| 52 | process.exit(1); | ||
| 53 | } | ||
| @@ -15,6 +15,7 @@ import { PGlite } from "@electric-sql/pglite"; · 14 unmodified lines | |||
| 15 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; | 15 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| 16 | import { drizzle } from "drizzle-orm/pglite"; | 16 | import { drizzle } from "drizzle-orm/pglite"; |
| 17 | import { migrate } from "drizzle-orm/pglite/migrator"; | 17 | import { migrate } from "drizzle-orm/pglite/migrator"; |
| 18 | import postgres from "postgres"; | ||
| 18 | 19 | ||
| 19 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 20 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 20 | // Port + data dir default to the dev values but are env-overridable so a second | 21 | // Port + data dir default to the dev values but are env-overridable so a second |
| @@ -104,25 +105,46 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); · 83 unmodified lines | |||
| 104 | // but prepared statement requires M" -> random 500s on whichever request lost | 105 | // but prepared statement requires M" -> random 500s on whichever request lost |
| 105 | // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch | 106 | // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch |
| 106 | // batches each socket data event into one queue entry and holds handler | 107 | // batches each socket data event into one queue entry and holds handler |
| 107 | // affinity while a pipeline is open; | 108 | // affinity while a pipeline is open. The patch also fixes the queue's two |
| 108 | // src/db/dev-db-socket-concurrency.node.test.ts is the regression test. | 109 | // self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn: |
| 109 | const server = new PGLiteSocketServer({ | 110 | // callback set no session (500)" cascade, where new connections' startup |
| 110 | db, | 111 | // packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart: |
| 111 | port: PORT, | 112 | // 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at |
| 112 | host: "127.0.0.1", | 113 | // the JS level, leaving its `processing` flag latched true; nothing was |
| 113 | maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), | 114 | // ever dequeued again. |
| 114 | // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse | 115 | // 2. A client whose socket died WHILE its pipeline-opening entry executed: |
| 115 | // sent, no Sync) with its socket still OPEN would hold the queue's handler | 116 | // detach() cleared affinity before the entry finished, the queue then |
| 116 | // affinity forever and starve every other connection, since affinity only | 117 | // took affinity for the already-dead handler, and no timer was left to |
| 117 | // releases on detach and detach needs close/error/idle-timeout. In ms; the | 118 | // release it. The queue now tracks detached handlers and repairs any |
| 118 | // timer resets on every data event, so only a genuinely dead client trips it. | 119 | // transaction or pipeline affinity they can no longer release. |
| 119 | idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), | 120 | // src/db/dev-db-socket-concurrency.node.test.ts is the regression test for |
| 120 | }); | 121 | // all of the above. |
| 122 | const makeServer = () => | ||
| 123 | new PGLiteSocketServer({ | ||
| 124 | db, | ||
| 125 | port: PORT, | ||
| 126 | host: "127.0.0.1", | ||
| 127 | maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), | ||
| 128 | // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse | ||
| 129 | // sent, no Sync) with its socket still OPEN would hold the queue's handler | ||
| 130 | // affinity forever and starve every other connection, since affinity only | ||
| 131 | // releases on detach and detach needs close/error/idle-timeout. In ms; the | ||
| 132 | // timer resets on every data event. The patch scopes the reap to connections | ||
| 133 | // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest | ||
| 134 | // connection is the normal state of a healthy postgres.js pool held by a | ||
| 135 | // long-lived scope (SSE), and reaping those raced live queries into | ||
| 136 | // sporadic `write CONNECTION_ENDED` 500s. | ||
| 137 | idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), | ||
| 138 | }); | ||
| 121 | 139 | ||
| 140 | let server = makeServer(); | ||
| 122 | await server.start(); | 141 | await server.start(); |
| 123 | console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); | 142 | console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); |
| 124 | 143 | ||
| 144 | let stopping = false; | ||
| 145 | |||
| 125 | const shutdown = async () => { | 146 | const shutdown = async () => { |
| 147 | stopping = true; | ||
| 126 | console.log("\n[dev-db] Shutting down"); | 148 | console.log("\n[dev-db] Shutting down"); |
| 127 | await server.stop(); | 149 | await server.stop(); |
| 128 | await db.close(); | 150 | await db.close(); |
| @@ -131,3 +153,112 @@ const shutdown = async () => { · 2 unmodified lines | |||
| 131 | 153 | ||
| 132 | process.on("SIGINT", shutdown); | 154 | process.on("SIGINT", shutdown); |
| 133 | process.on("SIGTERM", shutdown); | 155 | process.on("SIGTERM", shutdown); |
| 156 | |||
| 157 | // --------------------------------------------------------------------------- | ||
| 158 | // Wedge watchdog | ||
| 159 | // --------------------------------------------------------------------------- | ||
| 160 | // | ||
| 161 | // Twice the socket server has shipped a state machine that could stop | ||
| 162 | // answering NEW connections while the process, the port, and PGlite all stayed | ||
| 163 | // up (the CI e2e "cloud signIn: callback set no session (500)" cascades: every | ||
| 164 | // in-flight query dies once, then every fresh connection's startup packet | ||
| 165 | // times out — CONNECT_TIMEOUT — for the rest of the shard). The known paths | ||
| 166 | // are patched with regression tests, but each recurrence so far has found a | ||
| 167 | // new path, and a wedged front-end turns ONE infra hiccup into a failure of | ||
| 168 | // every remaining test in the shard. | ||
| 169 | // | ||
| 170 | // So: probe the server the way the app does — a fresh TCP connection, real | ||
| 171 | // startup handshake, `select 1` — and when several consecutive probes fail, | ||
| 172 | // dump the server's internals to the boot log and swap in a fresh socket | ||
| 173 | // server on the same PGlite instance (all state is in PGlite; the front-end is | ||
| 174 | // stateless, so this drops only already-doomed connections). If a restart | ||
| 175 | // doesn't restore service, exit non-zero: the boot supervisor logs the exit | ||
| 176 | // loudly and the run fails fast with an attributable cause instead of minutes | ||
| 177 | // of anonymous CONNECT_TIMEOUTs. The wedge itself stays visible in the | ||
| 178 | // server-logs artifact via the [dev-db][watchdog] lines. | ||
| 179 | const WATCHDOG_INTERVAL_MS = Number(process.env.DEV_DB_WATCHDOG_INTERVAL_MS ?? 5_000); | ||
| 180 | // 3 consecutive failures ≈ 15s+ of hard unavailability. PGlite serves queries | ||
| 181 | // in milliseconds; even a deep queue clears in well under one probe interval, | ||
| 182 | // so consecutive startup failures this sustained only happen wedged. | ||
| 183 | const WATCHDOG_FAILURES_TO_RESTART = 3; | ||
| 184 | const WATCHDOG_MAX_RESTARTS = 3; | ||
| 185 | |||
| 186 | const probe = async (): Promise<void> => { | ||
| 187 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { | ||
| 188 | max: 1, | ||
| 189 | idle_timeout: 0, | ||
| 190 | connect_timeout: 5, | ||
| 191 | fetch_types: false, | ||
| 192 | prepare: false, | ||
| 193 | onnotice: () => undefined, | ||
| 194 | }); | ||
| 195 | try { | ||
| 196 | // connect_timeout only bounds the handshake; race the query too so a | ||
| 197 | // post-startup wedge cannot hang the watchdog itself. | ||
| 198 | await Promise.race([ | ||
| 199 | sql.unsafe("select 1"), | ||
| 200 | sleep(10_000).then(() => { | ||
| 201 | throw new Error("probe query timed out after 10s"); | ||
| 202 | }), | ||
| 203 | ]); | ||
| 204 | } finally { | ||
| 205 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 206 | } | ||
| 207 | }; | ||
| 208 | |||
| 209 | const watchdog = async () => { | ||
| 210 | let consecutiveFailures = 0; | ||
| 211 | let restarts = 0; | ||
| 212 | for (;;) { | ||
| 213 | await sleep(WATCHDOG_INTERVAL_MS); | ||
| 214 | if (stopping) return; | ||
| 215 | try { | ||
| 216 | await probe(); | ||
| 217 | consecutiveFailures = 0; | ||
| 218 | } catch (cause) { | ||
| 219 | consecutiveFailures += 1; | ||
| 220 | console.error( | ||
| 221 | `[dev-db][watchdog] probe failed (${consecutiveFailures}/${WATCHDOG_FAILURES_TO_RESTART}): ${String(cause)}`, | ||
| 222 | ); | ||
| 223 | if (consecutiveFailures < WATCHDOG_FAILURES_TO_RESTART) continue; | ||
| 224 | console.error( | ||
| 225 | `[dev-db][watchdog] socket server wedged; stats: ${JSON.stringify(server.getStats())}`, | ||
| 226 | ); | ||
| 227 | if (restarts >= WATCHDOG_MAX_RESTARTS) { | ||
| 228 | console.error( | ||
| 229 | `[dev-db][watchdog] still wedged after ${restarts} restarts — giving up so the boot supervisor reports it`, | ||
| 230 | ); | ||
| 231 | process.exit(1); | ||
| 232 | } | ||
| 233 | restarts += 1; | ||
| 234 | consecutiveFailures = 0; | ||
| 235 | console.error( | ||
| 236 | `[dev-db][watchdog] restarting socket server (${restarts}/${WATCHDOG_MAX_RESTARTS})`, | ||
| 237 | ); | ||
| 238 | // stop() itself goes through the query queue (detach rolls back open | ||
| 239 | // transactions), so a wedge deep enough can hang the restart too — | ||
| 240 | // bound it and treat that as fatal rather than hanging the watchdog. | ||
| 241 | const restart = async () => { | ||
| 242 | await server.stop(); | ||
| 243 | server = makeServer(); | ||
| 244 | await server.start(); | ||
| 245 | }; | ||
| 246 | try { | ||
| 247 | await Promise.race([ | ||
| 248 | restart(), | ||
| 249 | sleep(15_000).then(() => { | ||
| 250 | throw new Error("restart timed out after 15s"); | ||
| 251 | }), | ||
| 252 | ]); | ||
| 253 | console.error(`[dev-db][watchdog] socket server restarted`); | ||
| 254 | } catch (restartCause) { | ||
| 255 | console.error( | ||
| 256 | `[dev-db][watchdog] restart failed (${String(restartCause)}) — exiting so the boot supervisor reports it`, | ||
| 257 | ); | ||
| 258 | process.exit(1); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 262 | }; | ||
| 263 | |||
| 264 | void watchdog(); | ||
| @@ -0,0 +1,124 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Measure the Worker's *evaluated* module closures from the build output. | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // Upload size does not predict cold-isolate cost: a Worker can ship megabytes | ||
| 6 | // that never load, and the bytes that matter are the ones an isolate must | ||
| 7 | // evaluate before it can answer. Rollup already separates static from dynamic | ||
| 8 | // edges, so we can compute that split directly instead of reading totals. | ||
| 9 | // | ||
| 10 | // Two closures matter: | ||
| 11 | // startup - statically reachable from the Worker entry. Evaluated on every | ||
| 12 | // cold isolate before any request is served. | ||
| 13 | // start - the TanStack Start server graph, reached through the lazy | ||
| 14 | // `loadEntries` dynamic imports. Evaluated on the first request | ||
| 15 | // that enters the app - i.e. a page request. | ||
| 16 | // app - the Effect app plane. `/api/*` dispatches at the Worker entry and | ||
| 17 | // skips Start entirely, so an API request evaluates this instead of | ||
| 18 | // `start`; reported separately because the two planes now diverge. | ||
| 19 | // | ||
| 20 | // Anything reachable only through a dynamic import is not counted: making a | ||
| 21 | // heavy dependency lazy is exactly the outcome this rewards. | ||
| 22 | // | ||
| 23 | // Scope note: this measures bytes, which is a proxy for cold-start cost, not a | ||
| 24 | // proven cause of any particular regression. The Aug 2026 MCP incident was | ||
| 25 | // NOT explained by this number - reverting the offending packages moved the | ||
| 26 | // evaluated closure by 0.02 MB while restoring production latency, so | ||
| 27 | // module-scope execution cost, not size, drove that one. Treat a budget breach | ||
| 28 | // as "this will make cold starts worse", not as "this is why the site is slow". | ||
| 29 | import { readFileSync, readdirSync, statSync } from "node:fs"; | ||
| 30 | import { dirname, join, relative, resolve } from "node:path"; | ||
| 31 | |||
| 32 | const DIST = resolve(process.argv[2] ?? "dist/server"); | ||
| 33 | const ENTRY = join(DIST, "index.js"); | ||
| 34 | |||
| 35 | // Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"` | ||
| 36 | // (all static) and `import("./x.js")` (dynamic). Matching the emitted output | ||
| 37 | // rather than source means we see the graph as the runtime sees it. | ||
| 38 | const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g; | ||
| 39 | const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g; | ||
| 40 | |||
| 41 | const listChunks = (dir) => | ||
| 42 | readdirSync(dir, { withFileTypes: true }).flatMap((e) => | ||
| 43 | e.isDirectory() | ||
| 44 | ? listChunks(join(dir, e.name)) | ||
| 45 | : e.name.endsWith(".js") | ||
| 46 | ? [join(dir, e.name)] | ||
| 47 | : [], | ||
| 48 | ); | ||
| 49 | |||
| 50 | const graph = new Map(); | ||
| 51 | for (const file of listChunks(DIST)) { | ||
| 52 | const code = readFileSync(file, "utf8"); | ||
| 53 | const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1]))); | ||
| 54 | // A specifier inside `import(...)` also matches STATIC_RE's `import` branch, | ||
| 55 | // so subtract the dynamic set rather than trusting the static matches alone. | ||
| 56 | const staticDeps = new Set( | ||
| 57 | [...code.matchAll(STATIC_RE)] | ||
| 58 | .map((m) => resolve(dirname(file), m[1])) | ||
| 59 | .filter((p) => !dynamic.has(p)), | ||
| 60 | ); | ||
| 61 | graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic }); | ||
| 62 | } | ||
| 63 | |||
| 64 | /** Bytes evaluated when `roots` are loaded, following static edges only. */ | ||
| 65 | const closure = (roots) => { | ||
| 66 | const seen = new Set(); | ||
| 67 | const queue = [...roots]; | ||
| 68 | while (queue.length > 0) { | ||
| 69 | const file = queue.pop(); | ||
| 70 | if (seen.has(file) || !graph.has(file)) continue; | ||
| 71 | seen.add(file); | ||
| 72 | queue.push(...graph.get(file).static); | ||
| 73 | } | ||
| 74 | return seen; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0); | ||
| 78 | const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`; | ||
| 79 | const name = (f) => relative(DIST, f); | ||
| 80 | |||
| 81 | const startup = closure([ENTRY]); | ||
| 82 | // The lazy server-graph entries Start pulls on first request. | ||
| 83 | const startRoots = [...graph.get(ENTRY).dynamic].filter((f) => | ||
| 84 | /(start|router|tanstack)/.test(name(f)), | ||
| 85 | ); | ||
| 86 | const start = closure(startRoots); | ||
| 87 | const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f)); | ||
| 88 | const app = closure([ENTRY, ...appRoots]); | ||
| 89 | // The budget tracks the worst plane: whichever costs a cold isolate more. | ||
| 90 | const evaluated = new Set([...startup, ...start]); | ||
| 91 | |||
| 92 | const report = (label, files) => { | ||
| 93 | console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`); | ||
| 94 | const own = [...files].filter((f) => !startup.has(f) || label === "startup"); | ||
| 95 | for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) { | ||
| 96 | console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`); | ||
| 97 | } | ||
| 98 | }; | ||
| 99 | |||
| 100 | report("startup", startup); | ||
| 101 | report("start", start); | ||
| 102 | console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`); | ||
| 103 | console.log( | ||
| 104 | `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`, | ||
| 105 | ); | ||
| 106 | const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f)); | ||
| 107 | console.log( | ||
| 108 | `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`, | ||
| 109 | ); | ||
| 110 | |||
| 111 | const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0); | ||
| 112 | if (budget > 0) { | ||
| 113 | const actual = bytes(evaluated) / 1024 / 1024; | ||
| 114 | console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`); | ||
| 115 | if (actual > budget) { | ||
| 116 | console.error( | ||
| 117 | `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` + | ||
| 118 | `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` + | ||
| 119 | `new weight behind a dynamic import rather than raising the budget; run this\n` + | ||
| 120 | `script with no budget set to see the biggest members and what is already lazy.`, | ||
| 121 | ); | ||
| 122 | process.exit(1); | ||
| 123 | } | ||
| 124 | } | ||
| @@ -13,12 +13,26 @@ import { fileURLToPath } from "node:url"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 15 | 15 | ||
| 16 | const PORT = 5434; | 16 | const parsePort = (input: string | undefined): number => { |
| 17 | if (input === undefined) return 5434; | ||
| 18 | if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer"); | ||
| 19 | const port = Number(input); | ||
| 20 | if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { | ||
| 21 | throw new Error("CLOUD_TEST_DB_PORT must be between 1 and 65535"); | ||
| 22 | } | ||
| 23 | return port; | ||
| 24 | }; | ||
| 25 | |||
| 26 | const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT); | ||
| 17 | const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); | 27 | const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); |
| 18 | 28 | ||
| 19 | let db: PGlite | undefined; | 29 | let db: PGlite | undefined; |
| 20 | let server: PGLiteSocketServer | undefined; | 30 | let server: PGLiteSocketServer | undefined; |
| 21 | 31 | ||
| 32 | /** | ||
| 33 | * Starts the cloud unit-test database and returns teardown that releases every | ||
| 34 | * resource without allowing PGlite shutdown to erase Vitest's failure status. | ||
| 35 | */ | ||
| 22 | export default async function setup() { | 36 | export default async function setup() { |
| 23 | db = await PGlite.create(); | 37 | db = await PGlite.create(); |
| 24 | await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); | 38 | await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); |
| @@ -30,7 +44,12 @@ export default async function setup() { · 5 unmodified lines | |||
| 30 | console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); | 44 | console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); |
| 31 | 45 | ||
| 32 | return async () => { | 46 | return async () => { |
| 47 | // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on | ||
| 48 | // close. Preserve Vitest's failure status across that close so | ||
| 49 | // teardown can turn neither a red test green nor a green test red. | ||
| 50 | const testsFailed = process.exitCode === 1; | ||
| 33 | await server?.stop(); | 51 | await server?.stop(); |
| 34 | await db?.close(); | 52 | await db?.close(); |
| 53 | if (testsFailed) process.exitCode = 1; | ||
| 35 | }; | 54 | }; |
| 36 | } | 55 | } |
| @@ -0,0 +1,18 @@ | |||
| 1 | // Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module | ||
| 2 | // rule (rooted at the app dir) can statically compile it at build time. Workers | ||
| 3 | // forbid runtime WASM compilation, and the rule's glob won't reach the | ||
| 4 | // monorepo-root node_modules, so the bytes must live inside this app. | ||
| 5 | // | ||
| 6 | // Re-run after bumping @jitl/quickjs-wasmfile-release-sync: | ||
| 7 | // bun run scripts/vendor-quickjs-wasm.ts | ||
| 8 | import { copyFileSync } from "node:fs"; | ||
| 9 | import { createRequire } from "node:module"; | ||
| 10 | import { dirname, join } from "node:path"; | ||
| 11 | import { fileURLToPath } from "node:url"; | ||
| 12 | |||
| 13 | const require = createRequire(import.meta.url); | ||
| 14 | const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); | ||
| 15 | const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); | ||
| 16 | |||
| 17 | copyFileSync(source, dest); | ||
| 18 | console.log(`vendored ${source} -> ${dest}`); | ||
| @@ -72,13 +72,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 71 unmodified lines | |||
| 72 | 72 | ||
| 73 | // Built inside the request body so the WorkOS account service closes | 73 | // Built inside the request body so the WorkOS account service closes |
| 74 | // over the per-request `UserStoreService` (postgres socket) supplied by | 74 | // over the per-request `UserStoreService` (postgres socket) supplied by |
| 75 | // the combined request-scoped layer. | 75 | // the combined request-scoped layer. `local` keeps that promise: the |
| 76 | // `longLived` context re-applied below carries the boot `CurrentMemoMap`, | ||
| 77 | // so a shared build would hand overlapping requests one another's socket. | ||
| 76 | const accountProvider = yield* Effect.provide( | 78 | const accountProvider = yield* Effect.provide( |
| 77 | AccountProvider.asEffect(), | 79 | AccountProvider.asEffect(), |
| 78 | workosAccountProvider.pipe( | 80 | workosAccountProvider.pipe( |
| 79 | Layer.provide(ApiKeyService.WorkOS), | 81 | Layer.provide(ApiKeyService.WorkOS), |
| 80 | Layer.provide(Layer.succeed(AccountCaller)({ session })), | 82 | Layer.provide(Layer.succeed(AccountCaller)({ session })), |
| 81 | ), | 83 | ), |
| 84 | { local: true }, | ||
| 82 | ); | 85 | ); |
| 83 | return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider); | 86 | return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider); |
| 84 | }).pipe(Effect.provideContext(longLived)); | 87 | }).pipe(Effect.provideContext(longLived)); |
| @@ -0,0 +1,196 @@ | |||
| 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 | setMemberSeats: () => Effect.void, | ||
| 115 | }); | ||
| 116 | |||
| 117 | /** | ||
| 118 | * The provider under test, over an `ApiKeyService` whose org listing holds a | ||
| 119 | * single org-owned key. Revoked ids land in `revoked` so "no delete happened" | ||
| 120 | * is assertable, not merely inferred from the error. | ||
| 121 | */ | ||
| 122 | const providerWith = (accountId: string) => { | ||
| 123 | const revoked: string[] = []; | ||
| 124 | const stubApiKeys = Layer.succeed(ApiKeyService)({ | ||
| 125 | validate: () => Effect.die("revoke test does not validate keys"), | ||
| 126 | listUserKeys: () => Effect.succeed([]), | ||
| 127 | createUserKey: () => Effect.die("revoke test does not create user keys"), | ||
| 128 | revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"), | ||
| 129 | listOrgKeys: () => Effect.die("revoke test does not list org keys"), | ||
| 130 | createOrgKey: () => Effect.die("revoke test does not create org keys"), | ||
| 131 | revokeOrgKey: ({ keyId }) => | ||
| 132 | keyId === ORG_KEY | ||
| 133 | ? Effect.sync(() => { | ||
| 134 | revoked.push(keyId); | ||
| 135 | }) | ||
| 136 | : Effect.fail(new OrgApiKeyNotFound({ keyId })), | ||
| 137 | }); | ||
| 138 | |||
| 139 | const provider = AccountProvider.asEffect().pipe( | ||
| 140 | Effect.provide( | ||
| 141 | workosAccountProvider.pipe( | ||
| 142 | Layer.provide( | ||
| 143 | Layer.mergeAll( | ||
| 144 | stubWorkOS, | ||
| 145 | stubUsers, | ||
| 146 | stubApiKeys, | ||
| 147 | stubAutumn, | ||
| 148 | Layer.succeed(AccountCaller)({ session: session(accountId) }), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | ), | ||
| 152 | ), | ||
| 153 | ); | ||
| 154 | |||
| 155 | return { provider, revoked }; | ||
| 156 | }; | ||
| 157 | |||
| 158 | describe("revokeOrgApiKey · provider boundary", () => { | ||
| 159 | it.effect("an org admin revokes an org-owned key", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const { provider, revoked } = providerWith(ADMIN); | ||
| 162 | const account = yield* provider; | ||
| 163 | |||
| 164 | const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY); | ||
| 165 | |||
| 166 | expect(result).toEqual({ success: true }); | ||
| 167 | expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); | ||
| 168 | }), | ||
| 169 | ); | ||
| 170 | |||
| 171 | it.effect("a plain member is forbidden, and nothing is revoked", () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const { provider, revoked } = providerWith(MEMBER); | ||
| 174 | const account = yield* provider; | ||
| 175 | |||
| 176 | const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY)); | ||
| 177 | |||
| 178 | expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); | ||
| 179 | expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | |||
| 183 | it.effect("a user-scoped key id fails cleanly through this path", () => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | // Not found — not a 500, and emphatically not a silent success that would | ||
| 186 | // report a member's live personal credential as destroyed. | ||
| 187 | const { provider, revoked } = providerWith(ADMIN); | ||
| 188 | const account = yield* provider; | ||
| 189 | |||
| 190 | const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY)); | ||
| 191 | |||
| 192 | expect(error).toBeInstanceOf(AccountError); | ||
| 193 | expect(revoked).toEqual([]); | ||
| 194 | }), | ||
| 195 | ); | ||
| 196 | }); | ||
| @@ -14,6 +14,7 @@ import type { Session } from "../auth/middleware"; · 13 unmodified lines | |||
| 14 | import { WorkOSClient } from "../auth/workos"; | 14 | import { WorkOSClient } from "../auth/workos"; |
| 15 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; | 15 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; |
| 16 | import { AutumnService } from "../extensions/billing/service"; | 16 | import { AutumnService } from "../extensions/billing/service"; |
| 17 | import { forkReportMemberSeats } from "../extensions/billing/member-seats"; | ||
| 17 | import { | 18 | import { |
| 18 | countSeatsUsed, | 19 | countSeatsUsed, |
| 19 | getMemberLimitForPlan, | 20 | getMemberLimitForPlan, |
| @@ -82,7 +83,7 @@ export const workosAccountProvider: Layer.Layer< · 62 unmodified lines | |||
| 82 | // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — | 83 | // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — |
| 83 | // can be erased to `R = never`, as the neutral AccountProvider shape | 84 | // can be erased to `R = never`, as the neutral AccountProvider shape |
| 84 | // requires. Provided per method below. | 85 | // requires. Provided per method below. |
| 85 | const ctx = yield* Effect.context<WorkOSClient | UserStoreService>(); | 86 | const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>(); |
| 86 | 87 | ||
| 87 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly | 88 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly |
| 88 | // as the old inline `requireSession` did. | 89 | // as the old inline `requireSession` did. |
| @@ -91,16 +92,19 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 91 | ? Effect.succeed(caller.session) | 92 | ? Effect.succeed(caller.session) |
| 92 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); | 93 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); |
| 93 | 94 | ||
| 94 | // The org scope for an org-scoped request: the console URL's org (sent in | 95 | // 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 | // the selector header. FAIL CLOSED when it's missing — the session's own |
| 96 | // is re-checked live, so the header is a selector, not a trust boundary — | 97 | // org is a browser-global pinned to whichever org WorkOS last touched, so |
| 98 | // falling back to it scopes a multi-org user's request to the WRONG org | ||
| 99 | // (see workos-auth-provider.resolveSessionPrincipal). Membership is | ||
| 100 | // re-checked live, so the header is a selector, not a trust boundary — | ||
| 97 | // and two browser tabs on different orgs each send their own header, so | 101 | // and two browser tabs on different orgs each send their own header, so |
| 98 | // they stay independent (see organization.ts). Yields the session + | 102 | // they stay independent (see organization.ts). Yields the session + |
| 99 | // resolved org, or AccountNoOrganization. | 103 | // resolved org, or AccountNoOrganization. |
| 100 | const requireOrganization = (headers: AccountHeaders) => | 104 | const requireOrganization = (headers: AccountHeaders) => |
| 101 | Effect.gen(function* () { | 105 | Effect.gen(function* () { |
| 102 | const session = yield* requireSession(); | 106 | const session = yield* requireSession(); |
| 103 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | 107 | const selector = headers[ORG_SELECTOR_HEADER]; |
| 104 | if (!selector) { | 108 | if (!selector) { |
| 105 | return yield* new AccountNoOrganization(); | 109 | return yield* new AccountNoOrganization(); |
| 106 | } | 110 | } |
| @@ -180,9 +184,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines | |||
| 180 | me: (headers) => | 184 | me: (headers) => |
| 181 | Effect.gen(function* () { | 185 | Effect.gen(function* () { |
| 182 | const session = yield* requireSession(); | 186 | const session = yield* requireSession(); |
| 183 | // Same selector precedence as requireOrganization: the URL's org | 187 | // The ONE sanctioned fallback to the session org. /account/me is |
| 184 | // (header) drives /account/me so the shell reflects the org the tab | 188 | // identity, not tenant data: on a bare URL (no org slug yet, so no |
| 185 | // is viewing, not a session-global active org. | 189 | // header) it answers "which org should this browser land in", and |
| 190 | // the session's pinned org is the only candidate. Every org-scoped | ||
| 191 | // DATA read fails closed instead (requireOrganization above, | ||
| 192 | // resolveSessionPrincipal) — serving tenant data from this fallback | ||
| 193 | // is exactly the wrong-org connection-list bug (2026-07). With a | ||
| 194 | // header (any slugged URL), the URL's org drives the answer so the | ||
| 195 | // shell reflects the org the tab is viewing. | ||
| 186 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | 196 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; |
| 187 | const org = selector | 197 | const org = selector |
| 188 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( | 198 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| @@ -237,6 +247,52 @@ export const workosAccountProvider: Layer.Layer< · 48 unmodified lines | |||
| 237 | return { success: true }; | 247 | return { success: true }; |
| 238 | }), | 248 | }), |
| 239 | 249 | ||
| 250 | // Org keys read every user in the tenant through the `/admin/*` plane, so | ||
| 251 | // both paths are admin-gated — unlike personal keys, which any member may | ||
| 252 | // mint for themselves. | ||
| 253 | listOrgApiKeys: (headers) => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const { session, org } = yield* requireOrganization(headers); | ||
| 256 | yield* requireAdmin(session.accountId, org.id); | ||
| 257 | const keys = yield* apiKeys | ||
| 258 | .listOrgKeys({ organizationId: org.id }) | ||
| 259 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | ||
| 260 | return { apiKeys: keys }; | ||
| 261 | }), | ||
| 262 | |||
| 263 | createOrgApiKey: (headers, name) => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const { session, org } = yield* requireOrganization(headers); | ||
| 266 | yield* requireAdmin(session.accountId, org.id); | ||
| 267 | const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); | ||
| 268 | if (!trimmed) { | ||
| 269 | return yield* new AccountError({ message: "API key name is required" }); | ||
| 270 | } | ||
| 271 | return yield* apiKeys | ||
| 272 | .createOrgKey({ organizationId: org.id, name: trimmed }) | ||
| 273 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | ||
| 274 | }), | ||
| 275 | |||
| 276 | // Same admin gate as the mint. Note this deliberately does NOT reuse | ||
| 277 | // `revokeApiKey`: that path resolves ownership against the caller's | ||
| 278 | // personal keys, so an org key id is never in its owned set. Ownership | ||
| 279 | // here is resolved against the ORG's keys inside `revokeOrgKey`, which is | ||
| 280 | // what keeps a user-scoped (or foreign-org) key id from being destroyed | ||
| 281 | // through the org route — it surfaces as a 404-shaped AccountError, not a | ||
| 282 | // silent success and not a 500. | ||
| 283 | revokeOrgApiKey: (headers, apiKeyId) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const { session, org } = yield* requireOrganization(headers); | ||
| 286 | yield* requireAdmin(session.accountId, org.id); | ||
| 287 | yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( | ||
| 288 | Effect.catchTag("ApiKeyManagementError", toAccountError), | ||
| 289 | Effect.catchTag("OrgApiKeyNotFound", () => | ||
| 290 | Effect.fail(new AccountError({ message: "Organization API key not found" })), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | return { success: true }; | ||
| 294 | }), | ||
| 295 | |||
| 240 | listMembers: (headers) => | 296 | listMembers: (headers) => |
| 241 | Effect.gen(function* () { | 297 | Effect.gen(function* () { |
| 242 | const { session, org } = yield* requireOrganization(headers); | 298 | const { session, org } = yield* requireOrganization(headers); |
| @@ -309,6 +365,7 @@ export const workosAccountProvider: Layer.Layer< · 66 unmodified lines | |||
| 309 | yield* workos | 365 | yield* workos |
| 310 | .deleteOrgMembership(membershipId) | 366 | .deleteOrgMembership(membershipId) |
| 311 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 367 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| 368 | yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx)); | ||
| 312 | return { success: true }; | 369 | return { success: true }; |
| 313 | }), | 370 | }), |
| 314 | 371 | ||
| @@ -331,7 +388,9 @@ export const workosAccountProvider: Layer.Layer< · 16 unmodified lines | |||
| 331 | .updateOrganization(org.id, name) | 388 | .updateOrganization(org.id, name) |
| 332 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 389 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| 333 | yield* users | 390 | yield* users |
| 334 | .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name })) | 391 | .use("upsertOrganization", (s) => |
| 392 | s.upsertOrganization({ id: updated.id, name: updated.name }), | ||
| 393 | ) | ||
| 335 | .pipe(Effect.catchTag("UserStoreError", toAccountError)); | 394 | .pipe(Effect.catchTag("UserStoreError", toAccountError)); |
| 336 | return { name: updated.name }; | 395 | return { name: updated.name }; |
| 337 | }), | 396 | }), |
| @@ -0,0 +1,357 @@ | |||
| 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. `local` keeps that promise: the | ||
| 334 | // `longLived` context re-applied below carries the boot `CurrentMemoMap`, | ||
| 335 | // so a shared build would hand overlapping requests one another's socket. | ||
| 336 | const provider = yield* Effect.provide( | ||
| 337 | AdminUsersProvider.asEffect(), | ||
| 338 | workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)), | ||
| 339 | { local: true }, | ||
| 340 | ); | ||
| 341 | return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider); | ||
| 342 | }).pipe(Effect.provideContext(longLived)); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | /** | ||
| 347 | * The cloud admin-users route layer, mounted as an app extension under the same | ||
| 348 | * `/api` prefix as the rest of the cloud router. | ||
| 349 | */ | ||
| 350 | export const makeCloudAdminUsersRoutes = ( | ||
| 351 | rsLive: Layer.Layer<DbService | UserStoreService>, | ||
| 352 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, | ||
| 353 | ) => | ||
| 354 | makeAdminUsersApiLayer( | ||
| 355 | AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer, | ||
| 356 | options, | ||
| 357 | ); | ||
| @@ -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 | }; | ||
| @@ -21,11 +21,32 @@ · 20 unmodified lines | |||
| 21 | // for `acquireRelease`. | 21 | // for `acquireRelease`. |
| 22 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 23 | 23 | ||
| 24 | import { describe, it, expect } from "@effect/vitest"; | 24 | import { describe, it, expect, beforeEach } from "@effect/vitest"; |
| 25 | import { Context, Effect, Layer } from "effect"; | 25 | import { Context, Effect, Layer } from "effect"; |
| 26 | import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; | 26 | import { |
| 27 | HttpRouter, | ||
| 28 | HttpServer, | ||
| 29 | HttpServerRequest, | ||
| 30 | HttpServerResponse, | ||
| 31 | } from "effect/unstable/http"; | ||
| 27 | 32 | ||
| 28 | import { requestScopedMiddleware } from "@executor-js/api/server"; | 33 | import { |
| 34 | CodeExecutorProvider, | ||
| 35 | DbProvider, | ||
| 36 | EngineDecoratorNoop, | ||
| 37 | ExecutorService, | ||
| 38 | HostConfig, | ||
| 39 | PluginsProvider, | ||
| 40 | makeExecutionStackMiddleware, | ||
| 41 | requestScopedMiddleware, | ||
| 42 | textFailureStrategy, | ||
| 43 | type CodeExecutor, | ||
| 44 | type IdentityFailure, | ||
| 45 | type Principal, | ||
| 46 | } from "@executor-js/api/server"; | ||
| 47 | import { collectTables } from "@executor-js/sdk"; | ||
| 48 | import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal"; | ||
| 49 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; | ||
| 29 | 50 | ||
| 30 | import { RequestScopedServicesLive } from "./api/layers"; | 51 | import { RequestScopedServicesLive } from "./api/layers"; |
| 31 | import { makeApiLive } from "./api/router"; | 52 | import { makeApiLive } from "./api/router"; |
| @@ -195,3 +216,288 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 163 unmodified lines | |||
| 195 | expect(counts.releases).toBe(2); | 216 | expect(counts.releases).toBe(2); |
| 196 | }); | 217 | }); |
| 197 | }); | 218 | }); |
| 219 | |||
| 220 | // --------------------------------------------------------------------------- | ||
| 221 | // `ExecutionStackMiddleware` request scoping. | ||
| 222 | // | ||
| 223 | // `requestScopedMiddleware` gives every request a fresh `MemoMap`, so the | ||
| 224 | // per-request DB handle is genuinely per request (the suites above). But | ||
| 225 | // `makeExecutionStackMiddleware` captures the BOOT fiber's context once, at | ||
| 226 | // layer-construction time, and re-applies it to every request with | ||
| 227 | // `Effect.provideContext`. That captured context carries Effect's | ||
| 228 | // `CurrentMemoMap`, and `provideContext`'s merge lets the BOOT map overwrite | ||
| 229 | // the fresh per-request one. The per-request `Effect.provide(stackLayer)` then | ||
| 230 | // memoizes its build in the boot map, which every in-flight request fiber in | ||
| 231 | // the isolate shares. | ||
| 232 | // | ||
| 233 | // Sequential requests still rebuild (the memo entry is refcounted by observer | ||
| 234 | // and drops to zero when the request scope closes), so only OVERLAPPING | ||
| 235 | // requests are affected: the second request reuses the first request's stack | ||
| 236 | // build, and therefore the first request's database handle. On Cloudflare | ||
| 237 | // Workers that is a cross-request I/O violation; and when the owning request | ||
| 238 | // finishes, its scope finalizer closes the connection out from under the | ||
| 239 | // borrower, whose next query fails. | ||
| 240 | // | ||
| 241 | // These tests stand the real middleware up over per-request in-memory | ||
| 242 | // databases and pin all three symptoms. | ||
| 243 | // --------------------------------------------------------------------------- | ||
| 244 | |||
| 245 | interface TestDbHandle { | ||
| 246 | readonly id: number; | ||
| 247 | readonly db: SqliteTestFumaDb; | ||
| 248 | closed: boolean; | ||
| 249 | } | ||
| 250 | |||
| 251 | class TestDb extends Context.Service<TestDb, TestDbHandle>()("test/TestDb") {} | ||
| 252 | |||
| 253 | interface StackProbeState { | ||
| 254 | /** | ||
| 255 | * Databases created up front, one per request the case will make. Handing a | ||
| 256 | * ready database to the request-scoped layer keeps its acquire synchronous, | ||
| 257 | * so the first request to arrive is reliably the one that owns handle 1 — | ||
| 258 | * which is what makes the borrow direction deterministic below. | ||
| 259 | */ | ||
| 260 | readonly pool: readonly SqliteTestFumaDb[]; | ||
| 261 | /** Handles acquired by `requestScopedMiddleware` — one per request. */ | ||
| 262 | readonly handles: TestDbHandle[]; | ||
| 263 | /** How many times the stack layer's `DbProvider` was actually built. */ | ||
| 264 | builds: number; | ||
| 265 | /** The handle id each stack build read. One entry per build. */ | ||
| 266 | readonly builtWithHandleId: number[]; | ||
| 267 | /** | ||
| 268 | * What each request could see about connection ownership at the moment it | ||
| 269 | * performed its read-after-await, keyed by that request's await duration. | ||
| 270 | */ | ||
| 271 | readonly lateReadOwnership: Map< | ||
| 272 | number, | ||
| 273 | { | ||
| 274 | readonly ownHandleId: number; | ||
| 275 | readonly ownClosed: boolean; | ||
| 276 | readonly otherClosed: readonly boolean[]; | ||
| 277 | } | ||
| 278 | >; | ||
| 279 | } | ||
| 280 | |||
| 281 | const noopCodeExecutor: CodeExecutor = { | ||
| 282 | execute: () => Effect.succeed({ result: undefined, logs: [] }), | ||
| 283 | }; | ||
| 284 | |||
| 285 | const stackProbePrincipal: Principal = { | ||
| 286 | kind: "member", | ||
| 287 | accountId: "user_request_scope", | ||
| 288 | organizationId: "org_request_scope", | ||
| 289 | organizationName: "Request Scope Test Org", | ||
| 290 | email: "request-scope@test.local", | ||
| 291 | name: "Request Scope", | ||
| 292 | avatarUrl: null, | ||
| 293 | roles: ["admin"], | ||
| 294 | }; | ||
| 295 | |||
| 296 | /** | ||
| 297 | * The per-request DB handle, in the slot cloud's postgres.js socket occupies: | ||
| 298 | * acquired when the request fiber's scope opens, closed when it closes. | ||
| 299 | */ | ||
| 300 | const makeTestDbLive = (state: StackProbeState) => | ||
| 301 | Layer.effect(TestDb)( | ||
| 302 | Effect.acquireRelease( | ||
| 303 | Effect.sync(() => { | ||
| 304 | const index = state.handles.length; | ||
| 305 | const handle: TestDbHandle = { | ||
| 306 | id: index + 1, | ||
| 307 | db: state.pool[index] as SqliteTestFumaDb, | ||
| 308 | closed: false, | ||
| 309 | }; | ||
| 310 | state.handles.push(handle); | ||
| 311 | return handle; | ||
| 312 | }), | ||
| 313 | (handle) => | ||
| 314 | // The request's own scope ends its connection, exactly as the host | ||
| 315 | // closes the postgres socket when the request finishes. | ||
| 316 | Effect.promise(async () => { | ||
| 317 | handle.closed = true; | ||
| 318 | await handle.db.close(); | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | ); | ||
| 322 | |||
| 323 | /** | ||
| 324 | * The host's `stackLayer` seam, reduced to the parts that matter: a | ||
| 325 | * `DbProvider` derived from the REQUEST-scoped handle (exactly cloud's | ||
| 326 | * arrangement) plus inert stand-ins for the rest. The 5ms sleep widens the | ||
| 327 | * window in which two request fibers overlap on the layer build, which is what | ||
| 328 | * an isolate serving concurrent requests does on its own. | ||
| 329 | */ | ||
| 330 | const makeTestStackLayer = (state: StackProbeState) => | ||
| 331 | Layer.mergeAll( | ||
| 332 | Layer.effect(DbProvider)( | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const handle = yield* TestDb; | ||
| 335 | yield* Effect.sleep("5 millis"); | ||
| 336 | state.builds += 1; | ||
| 337 | state.builtWithHandleId.push(handle.id); | ||
| 338 | return handle.db; | ||
| 339 | }), | ||
| 340 | ), | ||
| 341 | Layer.succeed(PluginsProvider)({ plugins: () => [] }), | ||
| 342 | Layer.succeed(HostConfig)({ allowLocalNetwork: false, oauthCallbackPath: "/oauth/callback" }), | ||
| 343 | Layer.succeed(CodeExecutorProvider)(noopCodeExecutor), | ||
| 344 | EngineDecoratorNoop, | ||
| 345 | ); | ||
| 346 | |||
| 347 | /** | ||
| 348 | * `GET /probe?delay=N`: one cheap read, a pause standing in for a slow | ||
| 349 | * outbound call, then a second read. That is the production shape — an early | ||
| 350 | * query succeeds, the request awaits something slow, and by the time it queries | ||
| 351 | * again the connection it borrowed has been closed by its real owner. | ||
| 352 | */ | ||
| 353 | const makeStackProbeRoutes = (state: StackProbeState) => | ||
| 354 | HttpRouter.add( | ||
| 355 | "GET", | ||
| 356 | "/probe", | ||
| 357 | Effect.gen(function* () { | ||
| 358 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 359 | const delayMs = Number( | ||
| 360 | new URL(request.url, "http://test.local").searchParams.get("delay") ?? "0", | ||
| 361 | ); | ||
| 362 | const executor = yield* ExecutorService; | ||
| 363 | const read = () => | ||
| 364 | executor.connections.list().pipe( | ||
| 365 | Effect.as("ok" as const), | ||
| 366 | Effect.catchCause(() => Effect.succeed("failed" as const)), | ||
| 367 | ); | ||
| 368 | |||
| 369 | // The handle this request acquired for itself, whatever order the | ||
| 370 | // request fibers happened to acquire in. | ||
| 371 | const own = yield* TestDb; | ||
| 372 | |||
| 373 | const early = yield* read(); | ||
| 374 | yield* Effect.sleep(`${delayMs} millis`); | ||
| 375 | state.lateReadOwnership.set(delayMs, { | ||
| 376 | ownHandleId: own.id, | ||
| 377 | ownClosed: own.closed, | ||
| 378 | otherClosed: state.handles.filter((handle) => handle.id !== own.id).map((h) => h.closed), | ||
| 379 | }); | ||
| 380 | const late = yield* read(); | ||
| 381 | return HttpServerResponse.jsonUnsafe({ early, late }); | ||
| 382 | }), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const makeStackProbeHandler = (state: StackProbeState) => { | ||
| 386 | const ExecutionStackMiddleware = makeExecutionStackMiddleware< | ||
| 387 | readonly [], | ||
| 388 | IdentityFailure, | ||
| 389 | never, | ||
| 390 | TestDb, | ||
| 391 | never, | ||
| 392 | never | ||
| 393 | >({ | ||
| 394 | plugins: [], | ||
| 395 | authenticate: () => Effect.succeed(stackProbePrincipal), | ||
| 396 | strategy: textFailureStrategy, | ||
| 397 | stackLayer: makeTestStackLayer(state), | ||
| 398 | }); | ||
| 399 | |||
| 400 | const App = makeStackProbeRoutes(state).pipe( | ||
| 401 | // Exactly cloud's composition: the stack middleware with the per-request | ||
| 402 | // DB layer combined in, so `DbProvider` is built over a handle owned by the | ||
| 403 | // request fiber's scope. | ||
| 404 | Layer.provide( | ||
| 405 | ExecutionStackMiddleware.combine(requestScopedMiddleware(makeTestDbLive(state))).layer, | ||
| 406 | ), | ||
| 407 | Layer.provideMerge(HttpServer.layerServices), | ||
| 408 | ); | ||
| 409 | return HttpRouter.toWebHandler(App, { disableLogger: true }).handler; | ||
| 410 | }; | ||
| 411 | |||
| 412 | const makeStackProbeState = async (requestCount: number): Promise<StackProbeState> => { | ||
| 413 | const pool: SqliteTestFumaDb[] = []; | ||
| 414 | for (let index = 0; index < requestCount; index += 1) { | ||
| 415 | pool.push(await createSqliteTestFumaDb({ tables: collectTables() })); | ||
| 416 | } | ||
| 417 | return { | ||
| 418 | pool, | ||
| 419 | handles: [], | ||
| 420 | builds: 0, | ||
| 421 | builtWithHandleId: [], | ||
| 422 | lateReadOwnership: new Map(), | ||
| 423 | }; | ||
| 424 | }; | ||
| 425 | |||
| 426 | describe("ExecutionStackMiddleware request scoping", () => { | ||
| 427 | beforeEach(() => { | ||
| 428 | // `makeExecutionStack` touches the subject once per build, behind a | ||
| 429 | // process-local throttle. Reset it so every case does the same work. | ||
| 430 | resetSubjectTouchCache(); | ||
| 431 | }); | ||
| 432 | |||
| 433 | it("builds the execution stack once per concurrent request", async () => { | ||
| 434 | const state = await makeStackProbeState(3); | ||
| 435 | const handler = makeStackProbeHandler(state); | ||
| 436 | |||
| 437 | const responses = await Promise.all([ | ||
| 438 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 439 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 440 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 441 | ]); | ||
| 442 | |||
| 443 | expect(responses.map((response) => response.status)).toEqual([200, 200, 200]); | ||
| 444 | // Three requests, three request-scoped handles, three stack builds. A lower | ||
| 445 | // build count means some request served its work on a stack built for a | ||
| 446 | // different request. | ||
| 447 | expect(state.handles.length).toBe(3); | ||
| 448 | expect(state.builds).toBe(3); | ||
| 449 | }); | ||
| 450 | |||
| 451 | it("never builds a request's stack over another request's database handle", async () => { | ||
| 452 | const state = await makeStackProbeState(3); | ||
| 453 | const handler = makeStackProbeHandler(state); | ||
| 454 | |||
| 455 | await Promise.all([ | ||
| 456 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 457 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 458 | handler(new Request("http://test.local/probe"), Context.empty()), | ||
| 459 | ]); | ||
| 460 | |||
| 461 | // Every acquired handle must be the one its own request's stack was built | ||
| 462 | // over: three builds reading three distinct handles, covering all three. | ||
| 463 | const acquired = state.handles.map((handle) => handle.id); | ||
| 464 | expect(acquired).toEqual([1, 2, 3]); | ||
| 465 | expect([...state.builtWithHandleId].sort()).toEqual(acquired); | ||
| 466 | expect(new Set(state.builtWithHandleId).size).toBe(state.handles.length); | ||
| 467 | }); | ||
| 468 | |||
| 469 | it("keeps a slow request's connection usable after a faster overlapping request ends", async () => { | ||
| 470 | const state = await makeStackProbeState(2); | ||
| 471 | const handler = makeStackProbeHandler(state); | ||
| 472 | |||
| 473 | // The fast request is served first, so it acquires handle 1. It finishes | ||
| 474 | // while the slow request is still awaiting, which is the window in which a | ||
| 475 | // borrowed connection gets pulled out from under its borrower. | ||
| 476 | const [fast, slow] = await Promise.all([ | ||
| 477 | handler(new Request("http://test.local/probe?delay=0"), Context.empty()), | ||
| 478 | handler(new Request("http://test.local/probe?delay=80"), Context.empty()), | ||
| 479 | ]); | ||
| 480 | |||
| 481 | // Read, slow await, read again — both reads succeed for both requests. | ||
| 482 | expect(await fast.json()).toEqual({ early: "ok", late: "ok" }); | ||
| 483 | expect(await slow.json()).toEqual({ early: "ok", late: "ok" }); | ||
| 484 | |||
| 485 | // Ownership at the read-after-await is the real assertion. The fast | ||
| 486 | // request has ended and released its connection by then, so the slow | ||
| 487 | // request is reading in exactly the window that breaks in production — | ||
| 488 | // and its OWN connection is still open, because that is the one its stack | ||
| 489 | // was built over. | ||
| 490 | // | ||
| 491 | // Sharing one build inverts this: the slow request's own connection is | ||
| 492 | // released while it is still running, and it keeps reading on the fast | ||
| 493 | // request's instead. | ||
| 494 | const slowView = state.lateReadOwnership.get(80); | ||
| 495 | expect(slowView?.ownClosed).toBe(false); | ||
| 496 | expect(slowView?.otherClosed).toEqual([true]); | ||
| 497 | |||
| 498 | // Each request built its stack over its own connection. | ||
| 499 | expect(state.builds).toBe(2); | ||
| 500 | expect([...state.builtWithHandleId].sort()).toEqual([1, 2]); | ||
| 501 | expect(state.lateReadOwnership.get(0)?.ownHandleId).not.toBe(slowView?.ownHandleId); | ||
| 502 | }); | ||
| 503 | }); | ||
| @@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 12 unmodified lines | |||
| 13 | Effect.succeed( | 13 | Effect.succeed( |
| 14 | value === "valid_user_key" | 14 | value === "valid_user_key" |
| 15 | ? { | 15 | ? { |
| 16 | scope: "user" as const, | ||
| 16 | accountId: "user_123", | 17 | accountId: "user_123", |
| 17 | organizationId: "org_123", | 18 | organizationId: "org_123", |
| 18 | keyId: "api_key_123", | 19 | keyId: "api_key_123", |
| @@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 3 unmodified lines | |||
| 22 | listUserKeys: () => Effect.succeed([]), | 23 | listUserKeys: () => Effect.succeed([]), |
| 23 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), | 24 | createUserKey: () => Effect.die("protected API auth test does not create API keys"), |
| 24 | revokeUserKey: () => Effect.void, | 25 | revokeUserKey: () => Effect.void, |
| 26 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 27 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 28 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 25 | }); | 29 | }); |
| 26 | 30 | ||
| 27 | const stubWorkOS = Layer.succeed( | 31 | const stubWorkOS = Layer.succeed( |
| @@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | |||
| 43 | ); | 47 | ); |
| 44 | 48 | ||
| 45 | const stubUsers = Layer.succeed(UserStoreService)({ | 49 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 46 | use: (fn) => | 50 | use: (_op, fn) => |
| 47 | Effect.promise(() => | 51 | Effect.promise(() => |
| 48 | fn({ | 52 | fn({ |
| 49 | ensureAccount: async (id: string) => ({ id, createdAt }), | 53 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -65,6 +69,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines | |||
| 65 | slug, | 69 | slug, |
| 66 | createdAt, | 70 | createdAt, |
| 67 | }), | 71 | }), |
| 72 | deleteOrganizationCascade: async () => {}, | ||
| 68 | }), | 73 | }), |
| 69 | ), | 74 | ), |
| 70 | }); | 75 | }); |
| @@ -84,6 +89,7 @@ describe("protected API key auth", () => { · 13 unmodified lines | |||
| 84 | ); | 89 | ); |
| 85 | 90 | ||
| 86 | expect(identity).toEqual({ | 91 | expect(identity).toEqual({ |
| 92 | kind: "member", | ||
| 87 | accountId: "user_123", | 93 | accountId: "user_123", |
| 88 | organizationId: "org_123", | 94 | organizationId: "org_123", |
| 89 | organizationName: "Org org_123", | 95 | organizationName: "Org org_123", |
| @@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 39 unmodified lines | |||
| 40 | listUserKeys: () => Effect.succeed([]), | 40 | listUserKeys: () => Effect.succeed([]), |
| 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), | 41 | createUserKey: () => Effect.die("JWT auth test does not create API keys"), |
| 42 | revokeUserKey: () => Effect.void, | 42 | revokeUserKey: () => Effect.void, |
| 43 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 44 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 45 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 43 | }); | 46 | }); |
| 44 | 47 | ||
| 45 | const stubWorkOS = Layer.succeed( | 48 | const stubWorkOS = Layer.succeed( |
| @@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 15 unmodified lines | |||
| 61 | ); | 64 | ); |
| 62 | 65 | ||
| 63 | const stubUsers = Layer.succeed(UserStoreService)({ | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 64 | use: (fn) => | 67 | use: (_op, fn) => |
| 65 | Effect.promise(() => | 68 | Effect.promise(() => |
| 66 | fn({ | 69 | fn({ |
| 67 | ensureAccount: async (id: string) => ({ id, createdAt }), | 70 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -83,6 +86,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 15 unmodified lines | |||
| 83 | slug, | 86 | slug, |
| 84 | createdAt, | 87 | createdAt, |
| 85 | }), | 88 | }), |
| 89 | deleteOrganizationCascade: async () => {}, | ||
| 86 | }), | 90 | }), |
| 87 | ), | 91 | ), |
| 88 | }); | 92 | }); |
| @@ -106,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { · 17 unmodified lines | |||
| 106 | const identity = yield* run(request(token), config); | 110 | const identity = yield* run(request(token), config); |
| 107 | 111 | ||
| 108 | expect(identity).toEqual({ | 112 | expect(identity).toEqual({ |
| 113 | kind: "member", | ||
| 109 | accountId: "user_123", | 114 | accountId: "user_123", |
| 110 | organizationId: "org_123", | 115 | organizationId: "org_123", |
| 111 | organizationName: "Org org_123", | 116 | organizationName: "Org org_123", |
| @@ -143,7 +148,10 @@ describe("protected JWT (device-login) auth", () => { · 31 unmodified lines | |||
| 143 | 148 | ||
| 144 | const error = yield* Effect.flip(run(request(token), config)); | 149 | const error = yield* Effect.flip(run(request(token), config)); |
| 145 | 150 | ||
| 146 | expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" }); | 151 | expect(error).toMatchObject({ |
| 152 | _tag: "Unauthorized", | ||
| 153 | code: "invalid_access_token", | ||
| 154 | }); | ||
| 147 | }), | 155 | }), |
| 148 | ); | 156 | ); |
| 149 | 157 | ||
| @@ -154,7 +162,10 @@ describe("protected JWT (device-login) auth", () => { · 4 unmodified lines | |||
| 154 | 162 | ||
| 155 | const error = yield* Effect.flip(run(request(token), config)); | 163 | const error = yield* Effect.flip(run(request(token), config)); |
| 156 | 164 | ||
| 157 | expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); | 165 | expect(error).toMatchObject({ |
| 166 | _tag: "NoOrganization", | ||
| 167 | code: "no_organization", | ||
| 168 | }); | ||
| 158 | }), | 169 | }), |
| 159 | ); | 170 | ); |
| 160 | 171 | ||
| @@ -166,7 +177,10 @@ describe("protected JWT (device-login) auth", () => { · 5 unmodified lines | |||
| 166 | 177 | ||
| 167 | const error = yield* Effect.flip(run(request(token), config)); | 178 | const error = yield* Effect.flip(run(request(token), config)); |
| 168 | 179 | ||
| 169 | expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); | 180 | expect(error).toMatchObject({ |
| 181 | _tag: "NoOrganization", | ||
| 182 | code: "no_organization", | ||
| 183 | }); | ||
| 170 | }), | 184 | }), |
| 171 | ); | 185 | ); |
| 172 | }); | 186 | }); |
| @@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => · 19 unmodified lines | |||
| 20 | pausedExecutionCount: () => Effect.succeed(0), | 20 | pausedExecutionCount: () => Effect.succeed(0), |
| 21 | hasPausedExecutions: () => Effect.succeed(false), | 21 | hasPausedExecutions: () => Effect.succeed(false), |
| 22 | getDescription: Effect.succeed("desc"), | 22 | getDescription: Effect.succeed("desc"), |
| 23 | // The fake forks nothing, so there is no sandbox fiber to end. | ||
| 24 | shutdown: Effect.void, | ||
| 23 | }) as ExecutionEngine; | 25 | }) as ExecutionEngine; |
| 24 | 26 | ||
| 25 | describe("withExecutionUsageTracking", () => { | 27 | describe("withExecutionUsageTracking", () => { |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { isAppOwnedPath } from "./app-paths"; | 3 | import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; |
| 4 | 4 | ||
| 5 | // Guards the start.ts dispatch decision: every surface the unified app handler | 5 | // Guards the start.ts dispatch decision: every surface the unified app handler |
| 6 | // serves must be classified app-owned (forwarded to `app.handler`), and Start's | 6 | // serves must be classified app-owned (forwarded to `app.handler`), and Start's |
| @@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { · 55 unmodified lines | |||
| 62 | }); | 62 | }); |
| 63 | } | 63 | } |
| 64 | }); | 64 | }); |
| 65 | |||
| 66 | describe("app-plane dispatch", () => { | ||
| 67 | // These two are the whole risk of dispatching `/api` before Start: both still | ||
| 68 | // return a response if routed early, just the wrong one, so nothing else would | ||
| 69 | // catch a regression here. | ||
| 70 | it("leaves the Sentry tunnel POST to Start's middleware", () => { | ||
| 71 | expect(servedByAppPlane("/api/sentry-tunnel", "POST")).toBe(false); | ||
| 72 | // Only the POST is claimed; anything else under that path is ordinary API. | ||
| 73 | expect(servedByAppPlane("/api/sentry-tunnel", "GET")).toBe(true); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("leaves the OAuth callback to Start, for the signed-out redirect", () => { | ||
| 77 | expect(servedByAppPlane("/api/oauth/callback", "GET")).toBe(false); | ||
| 78 | expect(servedByAppPlane("/api/oauth/callback", "POST")).toBe(false); | ||
| 79 | }); | ||
| 80 | |||
| 81 | const appPlane = [ | ||
| 82 | "/api/connections", | ||
| 83 | "/api/tools", | ||
| 84 | "/api/integrations", | ||
| 85 | "/api/account/members", | ||
| 86 | "/api/docs", | ||
| 87 | "/api/billing/checkout", | ||
| 88 | ]; | ||
| 89 | for (const pathname of appPlane) { | ||
| 90 | it(`serves ${pathname} without entering Start`, () => { | ||
| 91 | expect(servedByAppPlane(pathname, "GET")).toBe(true); | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | |||
| 95 | it("never claims a non-API path, however app-owned", () => { | ||
| 96 | expect(servedByAppPlane("/mcp", "POST")).toBe(false); | ||
| 97 | expect(servedByAppPlane("/", "GET")).toBe(false); | ||
| 98 | expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false); | ||
| 99 | }); | ||
| 100 | }); | ||
| @@ -17,3 +17,31 @@ export const isApiPath = (pathname: string) => pathname === "/api" || pathname.s · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | export const isAppOwnedPath = (pathname: string) => | 18 | export const isAppOwnedPath = (pathname: string) => |
| 19 | isApiPath(pathname) || classifyMcpPath(pathname) !== null; | 19 | isApiPath(pathname) || classifyMcpPath(pathname) !== null; |
| 20 | |||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | // Which plane serves an app-owned path: the Effect app directly, or TanStack | ||
| 23 | // Start's middleware chain. | ||
| 24 | // | ||
| 25 | // Everything under `/api` is pure Effect and touches no part of the router, | ||
| 26 | // React, or SSR — so `server.ts` dispatches it at the Worker entry and skips | ||
| 27 | // Start's lazy `loadEntries` import entirely. Two paths must NOT take that | ||
| 28 | // shortcut, because Start's request middleware claims them BEFORE the app | ||
| 29 | // handler would ever see them: | ||
| 30 | // | ||
| 31 | // POST /api/sentry-tunnel - `sentryTunnelMiddleware` forwards the envelope | ||
| 32 | // to Sentry; the app has no such route. | ||
| 33 | // /api/oauth/callback - `oauthCallbackSignInMiddleware` redirects a | ||
| 34 | // signed-out visitor to /login, and start.ts | ||
| 35 | // rewrites the org-scoped `state` before handing | ||
| 36 | // off. Routing it early would drop both. | ||
| 37 | // | ||
| 38 | // Getting this wrong is silent: the request still gets a response, just the | ||
| 39 | // wrong one, which is why it is classified here and tested rather than being | ||
| 40 | // an inline condition at the dispatch site. | ||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | |||
| 43 | export const isStartOwnedApiPath = (pathname: string, method: string): boolean => | ||
| 44 | (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback"; | ||
| 45 | |||
| 46 | export const servedByAppPlane = (pathname: string, method: string): boolean => | ||
| 47 | isApiPath(pathname) && !isStartOwnedApiPath(pathname, method); | ||
| @@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; · 12 unmodified lines | |||
| 13 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; | 13 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; |
| 14 | import { DbService } from "./db/db"; | 14 | import { DbService } from "./db/db"; |
| 15 | import { cloudMcpAuth } from "./mcp"; | 15 | import { cloudMcpAuth } from "./mcp"; |
| 16 | import { McpSessionDOSqlite } from "./mcp/session-durable-object"; | ||
| 17 | import { ErrorCaptureLive } from "./observability"; | 16 | import { ErrorCaptureLive } from "./observability"; |
| 18 | import { AutumnService } from "./extensions/billing/service"; | 17 | import { AutumnService } from "./extensions/billing/service"; |
| 19 | import { | 18 | import { |
| @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; · 11 unmodified lines | |||
| 31 | // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- | 30 | // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- |
| 32 | // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, | 31 | // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, |
| 33 | // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object | 32 | // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object |
| 34 | // session store (the DO surfaced via `config.mcpExport`), console+Sentry error | 33 | // session store (the DO class is exported straight from server.ts), console+Sentry error |
| 35 | // capture — and Autumn BILLING entering ONLY as extensions: the engine | 34 | // capture — and Autumn BILLING entering ONLY as extensions: the engine |
| 36 | // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, | 35 | // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, |
| 37 | // and the createOrganization free-limit gate. `diff` against | 36 | // and the createOrganization free-limit gate. `diff` against |
| @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); · 28 unmodified lines | |||
| 66 | // satisfied by `boot`, `DbService` per request via `requestScoped`). | 65 | // satisfied by `boot`, `DbService` per request via `requestScoped`). |
| 67 | const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider; | 66 | const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider; |
| 68 | 67 | ||
| 69 | const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ | 68 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| 70 | plugins: cloudPlugins, | 69 | plugins: cloudPlugins, |
| 71 | providers: { | 70 | providers: { |
| 72 | // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS | 71 | // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS |
| @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 39 unmodified lines | |||
| 112 | failure: cloudIdentityFailureStrategy, | 111 | failure: cloudIdentityFailureStrategy, |
| 113 | // The MCP session Durable Object class — a top-level Workers export a Layer | 112 | // The MCP session Durable Object class — a top-level Workers export a Layer |
| 114 | // can't return; surfaced so `server.ts` can re-export it. | 113 | // can't return; surfaced so `server.ts` can re-export it. |
| 115 | mcpExport: McpSessionDOSqlite, | ||
| 116 | }, | 114 | }, |
| 117 | // The long-lived (boot-scoped) context provideMerge'd under everything: the | 115 | // The long-lived (boot-scoped) context provideMerge'd under everything: the |
| 118 | // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request | 116 | // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request |
| @@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ · 16 unmodified lines | |||
| 135 | requestScoped: RequestScopedServicesLive, | 133 | requestScoped: RequestScopedServicesLive, |
| 136 | }); | 134 | }); |
| 137 | 135 | ||
| 138 | export { McpSessionDOSqlite }; | ||
| 139 | |||
| 140 | export const CloudAppLayer = appLayer; | 136 | export const CloudAppLayer = appLayer; |
| 141 | export const cloudMcpExport = mcpExport; | ||
| 142 | 137 | ||
| 143 | // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), | 138 | // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), |
| 144 | // /mcp, /.well-known/* — everything the worker dispatches. | 139 | // /mcp, /.well-known/* — everything the worker dispatches. |
| @@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { · 41 unmodified lines | |||
| 42 | }); | 42 | }); |
| 43 | 43 | ||
| 44 | expect(principal).toEqual({ | 44 | expect(principal).toEqual({ |
| 45 | scope: "user", | ||
| 45 | accountId: "user_123", | 46 | accountId: "user_123", |
| 46 | organizationId: "org_123", | 47 | organizationId: "org_123", |
| 47 | keyId: "api_key_123", | 48 | keyId: "api_key_123", |
| @@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { · 18 unmodified lines | |||
| 66 | }), | 67 | }), |
| 67 | ); | 68 | ); |
| 68 | 69 | ||
| 69 | it.effect("rejects missing, organization-owned, and org-less keys", () => | 70 | it.effect("rejects missing and org-less keys", () => |
| 70 | Effect.gen(function* () { | 71 | Effect.gen(function* () { |
| 71 | const missing = yield* validate({ apiKey: null }); | 72 | const missing = yield* validate({ apiKey: null }); |
| 72 | const orgOwned = yield* validate({ | ||
| 73 | apiKey: { | ||
| 74 | id: "api_key_org", | ||
| 75 | owner: { type: "organization", id: "org_123" }, | ||
| 76 | }, | ||
| 77 | }); | ||
| 78 | const orgLess = yield* validate({ | 73 | const orgLess = yield* validate({ |
| 79 | apiKey: { | 74 | apiKey: { |
| 80 | id: "api_key_no_org", | 75 | id: "api_key_no_org", |
| @@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { · 2 unmodified lines | |||
| 83 | }); | 78 | }); |
| 84 | 79 | ||
| 85 | expect(missing).toBeNull(); | 80 | expect(missing).toBeNull(); |
| 86 | expect(orgOwned).toBeNull(); | ||
| 87 | expect(orgLess).toBeNull(); | 81 | expect(orgLess).toBeNull(); |
| 88 | }), | 82 | }), |
| 89 | ); | 83 | ); |
| 90 | 84 | ||
| 85 | it.effect("resolves organization-owned keys to the org scope with no account", () => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | // An org key USED to fail the decode and come back null — indistinguishable | ||
| 88 | // from an invalid key. It now resolves explicitly: `owner.id` IS the | ||
| 89 | // organization, and there is no member behind it. | ||
| 90 | const principal = yield* validate({ | ||
| 91 | apiKey: { | ||
| 92 | id: "api_key_org", | ||
| 93 | owner: { type: "organization", id: "org_123" }, | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | |||
| 97 | expect(principal).toEqual({ | ||
| 98 | scope: "org", | ||
| 99 | accountId: null, | ||
| 100 | organizationId: "org_123", | ||
| 101 | keyId: "api_key_org", | ||
| 102 | }); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 91 | it.effect("lists and creates user-owned keys", () => | 106 | it.effect("lists and creates user-owned keys", () => |
| 92 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| 93 | const program = Effect.gen(function* () { | 108 | const program = Effect.gen(function* () { |
| @@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { ApiKeyManagementError } from "./errors"; | 3 | import { ApiKeyManagementError } from "./errors"; |
| 4 | import { WorkOSClient } from "./workos"; | 4 | import { WorkOSClient } from "./workos"; |
| 5 | 5 | ||
| 6 | /** | ||
| 7 | * Which view a validated key resolves to. | ||
| 8 | * - `"user"` — the product view. The key belongs to a member; the request | ||
| 9 | * binds to that member as the acting subject. Every key today is this. | ||
| 10 | * - `"org"` — the PLATFORM view. The key belongs to the organization itself, | ||
| 11 | * so there is no acting member: the request gets tenant-wide READ-ONLY | ||
| 12 | * reach and no bound subject. Privileged; minted separately (PR 1.5). | ||
| 13 | */ | ||
| 14 | export type ApiKeyScope = "user" | "org"; | ||
| 15 | |||
| 6 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / | 16 | /** The owner an api key resolves to — NOT a full {@link Principal} (no email / |
| 7 | * name / roles), so it carries an honest, distinct name. */ | 17 | * name / roles), so it carries an honest, distinct name. */ |
| 8 | export type ApiKeyOwner = { | 18 | export type ApiKeyOwner = { |
| 9 | readonly accountId: string; | 19 | readonly scope: ApiKeyScope; |
| 20 | /** The acting member for a `"user"` key; `null` for an `"org"` key, which | ||
| 21 | * has no member behind it. Nullable rather than a sentinel so nothing can | ||
| 22 | * accidentally bind an org key to a subject. */ | ||
| 23 | readonly accountId: string | null; | ||
| 10 | readonly organizationId: string; | 24 | readonly organizationId: string; |
| 11 | readonly keyId: string; | 25 | readonly keyId: string; |
| 12 | }; | 26 | }; |
| @@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 15 unmodified lines | |||
| 28 | readonly cause: unknown; | 42 | readonly cause: unknown; |
| 29 | }> {} | 43 | }> {} |
| 30 | 44 | ||
| 45 | /** | ||
| 46 | * The requested key id is not an org-owned key of this organization — it does | ||
| 47 | * not exist, belongs to a member rather than the org, or belongs to a different | ||
| 48 | * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an | ||
| 49 | * upstream failure) so callers can answer "not found" without a 500. | ||
| 50 | */ | ||
| 51 | export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{ | ||
| 52 | readonly keyId: string; | ||
| 53 | }> {} | ||
| 54 | |||
| 55 | // WorkOS keys are owned by either a user or an organization. The user shape | ||
| 56 | // carries the member id in `id` and the org in `organization_id`; the org shape | ||
| 57 | // carries the ORG in `id` and no member at all. Both decode here — the | ||
| 58 | // distinction is a branch (`ownerFromApiKey`), not a decode failure, so an | ||
| 59 | // org-owned key is no longer indistinguishable from an invalid one. | ||
| 31 | const UserApiKeyOwner = Schema.Struct({ | 60 | const UserApiKeyOwner = Schema.Struct({ |
| 32 | type: Schema.Literal("user"), | 61 | type: Schema.Literal("user"), |
| 33 | id: Schema.String, | 62 | id: Schema.String, |
| @@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ · 1 unmodified line | |||
| 35 | organization_id: Schema.optional(Schema.String), | 64 | organization_id: Schema.optional(Schema.String), |
| 36 | }); | 65 | }); |
| 37 | 66 | ||
| 67 | const OrgApiKeyOwner = Schema.Struct({ | ||
| 68 | type: Schema.Literal("organization"), | ||
| 69 | id: Schema.String, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]); | ||
| 73 | |||
| 38 | const ApiKey = Schema.Struct({ | 74 | const ApiKey = Schema.Struct({ |
| 39 | id: Schema.String, | 75 | id: Schema.String, |
| 40 | owner: UserApiKeyOwner, | 76 | owner: UserApiKeyOwner, |
| @@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ · 8 unmodified lines | |||
| 49 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), | 85 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 50 | }); | 86 | }); |
| 51 | 87 | ||
| 88 | // Validation accepts BOTH owner shapes. The user list/create paths below stay | ||
| 89 | // user-only on purpose (they are the console's personal-key CRUD); the ORG | ||
| 90 | // list/create paths are the separate, privileged surface beside them, gated to | ||
| 91 | // admins at the account-provider boundary. | ||
| 92 | const ValidatedApiKey = Schema.Struct({ | ||
| 93 | id: Schema.String, | ||
| 94 | owner: WorkOSApiKeyOwner, | ||
| 95 | }); | ||
| 96 | |||
| 52 | const ValidateApiKeyResponse = Schema.Struct({ | 97 | const ValidateApiKeyResponse = Schema.Struct({ |
| 53 | apiKey: Schema.NullOr(ApiKey), | 98 | apiKey: Schema.NullOr(ValidatedApiKey), |
| 54 | }); | 99 | }); |
| 55 | 100 | ||
| 56 | const RawCreatedApiKey = Schema.Struct({ | 101 | const RawCreatedApiKey = Schema.Struct({ |
| @@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ · 15 unmodified lines | |||
| 72 | data: Schema.Array(ApiKey), | 117 | data: Schema.Array(ApiKey), |
| 73 | }); | 118 | }); |
| 74 | 119 | ||
| 120 | // The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape | ||
| 121 | // rather than a widened one: `summaryFromApiKey` reads the user shape's | ||
| 122 | // `organization_id` to prove the key belongs to the caller's org, and an | ||
| 123 | // org-owned row carries the org in `owner.id` instead. Merging them would mean | ||
| 124 | // one nullable field standing for two different things. | ||
| 125 | const OrgApiKey = Schema.Struct({ | ||
| 126 | id: Schema.String, | ||
| 127 | owner: OrgApiKeyOwner, | ||
| 128 | name: Schema.optional(Schema.String), | ||
| 129 | obfuscatedValue: Schema.optional(Schema.String), | ||
| 130 | obfuscated_value: Schema.optional(Schema.String), | ||
| 131 | createdAt: Schema.optional(Schema.String), | ||
| 132 | created_at: Schema.optional(Schema.String), | ||
| 133 | updatedAt: Schema.optional(Schema.String), | ||
| 134 | updated_at: Schema.optional(Schema.String), | ||
| 135 | lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 136 | last_used_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 137 | }); | ||
| 138 | |||
| 139 | const RawCreatedOrgApiKey = Schema.Struct({ | ||
| 140 | ...OrgApiKey.fields, | ||
| 141 | value: Schema.String, | ||
| 142 | }); | ||
| 143 | |||
| 144 | // `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS | ||
| 145 | // may return user-owned keys alongside org-owned ones, and a whole-array decode | ||
| 146 | // fails on the first non-org element — blanking the entire list rather than | ||
| 147 | // dropping the one row that doesn't belong. Same per-row tolerance the user-key | ||
| 148 | // path gets from `summaryFromApiKey` returning null. | ||
| 149 | const ListOrgApiKeysResponse = Schema.Struct({ | ||
| 150 | data: Schema.Array(Schema.Unknown), | ||
| 151 | }); | ||
| 152 | |||
| 153 | const CreateOrgApiKeyResponse = Schema.Union([ | ||
| 154 | RawCreatedOrgApiKey, | ||
| 155 | Schema.Struct({ apiKey: RawCreatedOrgApiKey }), | ||
| 156 | Schema.Struct({ api_key: RawCreatedOrgApiKey }), | ||
| 157 | ]); | ||
| 158 | |||
| 75 | const CreateApiKeyResponse = Schema.Union([ | 159 | const CreateApiKeyResponse = Schema.Union([ |
| 76 | RawCreatedApiKey, | 160 | RawCreatedApiKey, |
| 77 | Schema.Struct({ apiKey: RawCreatedApiKey }), | 161 | Schema.Struct({ apiKey: RawCreatedApiKey }), |
| @@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ · 3 unmodified lines | |||
| 81 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); | 165 | const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); |
| 82 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); | 166 | const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); |
| 83 | const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); | 167 | const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); |
| 168 | const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse); | ||
| 169 | const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey); | ||
| 170 | const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse); | ||
| 171 | |||
| 172 | const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => { | ||
| 173 | if (apiKey.owner.type === "organization") { | ||
| 174 | // An org-owned key IS the org: `owner.id` is the organization, and there | ||
| 175 | // is no member to act as. This resolves to the platform view. | ||
| 176 | return { | ||
| 177 | scope: "org", | ||
| 178 | accountId: null, | ||
| 179 | organizationId: apiKey.owner.id, | ||
| 180 | keyId: apiKey.id, | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | ||
| 184 | if (!organizationId) return null; | ||
| 185 | return { | ||
| 186 | scope: "user", | ||
| 187 | accountId: apiKey.owner.id, | ||
| 188 | organizationId, | ||
| 189 | keyId: apiKey.id, | ||
| 190 | }; | ||
| 191 | }; | ||
| 84 | 192 | ||
| 85 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => | 193 | const ownerFromResponse = (value: unknown): ApiKeyOwner | null => |
| 86 | Option.match(decodeValidateApiKeyResponse(value), { | 194 | Option.match(decodeValidateApiKeyResponse(value), { |
| 87 | onNone: () => null, | 195 | onNone: () => null, |
| 88 | onSome: ({ apiKey }) => { | 196 | onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), |
| 89 | if (!apiKey) return null; | ||
| 90 | const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; | ||
| 91 | if (!organizationId) return null; | ||
| 92 | return { | ||
| 93 | accountId: apiKey.owner.id, | ||
| 94 | organizationId, | ||
| 95 | keyId: apiKey.id, | ||
| 96 | }; | ||
| 97 | }, | ||
| 98 | }); | 197 | }); |
| 99 | 198 | ||
| 100 | const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { | 199 | const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { |
| @@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => · 19 unmodified lines | |||
| 120 | }), | 219 | }), |
| 121 | }); | 220 | }); |
| 122 | 221 | ||
| 222 | // The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by | ||
| 223 | // the CALLER against the org it asked for: WorkOS's workspace api key is | ||
| 224 | // workspace-wide, so a response naming a different org than the one requested | ||
| 225 | // must not be surfaced as that org's key. | ||
| 226 | const summaryFromOrgApiKey = ( | ||
| 227 | apiKey: typeof OrgApiKey.Type, | ||
| 228 | organizationId: string, | ||
| 229 | ): ApiKeySummary | null => { | ||
| 230 | if (apiKey.owner.id !== organizationId) return null; | ||
| 231 | return { | ||
| 232 | id: apiKey.id, | ||
| 233 | name: apiKey.name ?? "API key", | ||
| 234 | obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "", | ||
| 235 | createdAt: apiKey.createdAt ?? apiKey.created_at ?? "", | ||
| 236 | updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "", | ||
| 237 | lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null, | ||
| 238 | }; | ||
| 239 | }; | ||
| 240 | |||
| 241 | const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] => | ||
| 242 | Option.match(decodeListOrgApiKeysResponse(value), { | ||
| 243 | onNone: () => [], | ||
| 244 | onSome: ({ data }) => | ||
| 245 | data.flatMap((entry) => | ||
| 246 | Option.match(decodeOrgApiKey(entry), { | ||
| 247 | // Not an org-owned key (a user key sharing the listing, or a shape we | ||
| 248 | // don't recognize) — skip the row, keep the rest. | ||
| 249 | onNone: () => [], | ||
| 250 | onSome: (apiKey) => { | ||
| 251 | const summary = summaryFromOrgApiKey(apiKey, organizationId); | ||
| 252 | return summary ? [summary] : []; | ||
| 253 | }, | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | }); | ||
| 257 | |||
| 258 | const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null => | ||
| 259 | Option.match(decodeCreateOrgApiKeyResponse(value), { | ||
| 260 | onNone: () => null, | ||
| 261 | onSome: (response) => { | ||
| 262 | const apiKey = | ||
| 263 | "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key; | ||
| 264 | const summary = summaryFromOrgApiKey(apiKey, organizationId); | ||
| 265 | return summary ? { ...summary, value: apiKey.value } : null; | ||
| 266 | }, | ||
| 267 | }); | ||
| 268 | |||
| 123 | const createdFromResponse = (value: unknown): CreatedApiKey | null => | 269 | const createdFromResponse = (value: unknown): CreatedApiKey | null => |
| 124 | Option.match(decodeCreateApiKeyResponse(value), { | 270 | Option.match(decodeCreateApiKeyResponse(value), { |
| 125 | onNone: () => null, | 271 | onNone: () => null, |
| @@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< · 21 unmodified lines | |||
| 147 | readonly revokeUserKey: (input: { | 293 | readonly revokeUserKey: (input: { |
| 148 | readonly keyId: string; | 294 | readonly keyId: string; |
| 149 | }) => Effect.Effect<void, ApiKeyManagementError>; | 295 | }) => Effect.Effect<void, ApiKeyManagementError>; |
| 296 | /** | ||
| 297 | * The org-owned keys of an organization — the credentials that resolve to | ||
| 298 | * the read-only platform view. Privileged: callers gate on admin membership | ||
| 299 | * before reaching this. | ||
| 300 | */ | ||
| 301 | readonly listOrgKeys: (input: { | ||
| 302 | readonly organizationId: string; | ||
| 303 | }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>; | ||
| 304 | readonly createOrgKey: (input: { | ||
| 305 | readonly organizationId: string; | ||
| 306 | readonly name: string; | ||
| 307 | }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>; | ||
| 308 | /** | ||
| 309 | * Revoke an org-owned key. Ownership is resolved against the ORG's own key | ||
| 310 | * listing — never the caller's personal keys — so a member's user key id | ||
| 311 | * (or another org's key id, since the WorkOS workspace key is | ||
| 312 | * workspace-wide) cannot be destroyed through this path. | ||
| 313 | */ | ||
| 314 | readonly revokeOrgKey: (input: { | ||
| 315 | readonly organizationId: string; | ||
| 316 | readonly keyId: string; | ||
| 317 | }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>; | ||
| 150 | } | 318 | } |
| 151 | >()("@executor-js/cloud/ApiKeyService") { | 319 | >()("@executor-js/cloud/ApiKeyService") { |
| 152 | static WorkOS = Layer.effect(this)( | 320 | static WorkOS = Layer.effect(this)( |
| @@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< · 24 unmodified lines | |||
| 177 | workos | 345 | workos |
| 178 | .deleteApiKey(keyId) | 346 | .deleteApiKey(keyId) |
| 179 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))), | 347 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))), |
| 348 | listOrgKeys: ({ organizationId }) => | ||
| 349 | workos.listOrgApiKeys(organizationId).pipe( | ||
| 350 | Effect.map((response) => orgListFromResponse(response, organizationId)), | ||
| 351 | Effect.mapError((cause) => new ApiKeyManagementError({ cause })), | ||
| 352 | ), | ||
| 353 | createOrgKey: ({ organizationId, name }) => | ||
| 354 | workos.createOrgApiKey({ organizationId, name }).pipe( | ||
| 355 | Effect.mapError((cause) => new ApiKeyManagementError({ cause })), | ||
| 356 | Effect.flatMap((response) => { | ||
| 357 | const created = orgCreatedFromResponse(response, organizationId); | ||
| 358 | return created | ||
| 359 | ? Effect.succeed(created) | ||
| 360 | : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" })); | ||
| 361 | }), | ||
| 362 | ), | ||
| 363 | // Ownership is checked against the ORG's decoded key listing before the | ||
| 364 | // delete goes out. `deleteApiKey` is scope-blind — handed any key id it | ||
| 365 | // would happily destroy a member's personal credential, or a key | ||
| 366 | // belonging to a sibling org in the same workspace. The listing already | ||
| 367 | // drops both of those (`orgListFromResponse` filters on owner type AND | ||
| 368 | // organization), so filtering through it is the whole authorization. | ||
| 369 | revokeOrgKey: ({ organizationId, keyId }) => | ||
| 370 | Effect.gen(function* () { | ||
| 371 | const response = yield* workos | ||
| 372 | .listOrgApiKeys(organizationId) | ||
| 373 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); | ||
| 374 | const owned = orgListFromResponse(response, organizationId); | ||
| 375 | if (!owned.some((key) => key.id === keyId)) { | ||
| 376 | return yield* new OrgApiKeyNotFound({ keyId }); | ||
| 377 | } | ||
| 378 | yield* workos | ||
| 379 | .deleteApiKey(keyId) | ||
| 380 | .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); | ||
| 381 | }), | ||
| 180 | }; | 382 | }; |
| 181 | }), | 383 | }), |
| 182 | ); | 384 | ); |
| @@ -44,6 +44,16 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines | |||
| 44 | slug: Schema.String, | 44 | slug: Schema.String, |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | // Deleting an org requires re-typing its name (the label the UI shows) as a | ||
| 48 | // deliberate, non-automatable confirmation. Verified server-side too. | ||
| 49 | const DeleteOrganizationBody = Schema.Struct({ | ||
| 50 | confirmName: Schema.String, | ||
| 51 | }); | ||
| 52 | |||
| 53 | const DeleteOrganizationResponse = Schema.Struct({ | ||
| 54 | success: Schema.Boolean, | ||
| 55 | }); | ||
| 56 | |||
| 47 | // CLI device-login discovery (`executor login`). Tells the CLI where to run | 57 | // CLI device-login discovery (`executor login`). Tells the CLI where to run |
| 48 | // the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client | 58 | // the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client |
| 49 | // to use. The CLI hits these provider endpoints directly, gets a WorkOS access | 59 | // to use. The CLI hits these provider endpoints directly, gets a WorkOS access |
| @@ -146,6 +156,15 @@ export class McpSessionForbiddenError extends Schema.TaggedErrorClass<McpSession · 96 unmodified lines | |||
| 146 | { httpApiStatus: 403 }, | 156 | { httpApiStatus: 403 }, |
| 147 | ) {} | 157 | ) {} |
| 148 | 158 | ||
| 159 | // Refused org deletion: the caller is not an admin of the org, or the typed | ||
| 160 | // confirmation did not match. Deliberately one error for both so it never | ||
| 161 | // reveals which check failed. | ||
| 162 | export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass<OrganizationDeletionForbidden>()( | ||
| 163 | "OrganizationDeletionForbidden", | ||
| 164 | {}, | ||
| 165 | { httpApiStatus: 403 }, | ||
| 166 | ) {} | ||
| 167 | |||
| 149 | export const AUTH_PATHS = { | 168 | export const AUTH_PATHS = { |
| 150 | login: "/api/auth/login", | 169 | login: "/api/auth/login", |
| 151 | logout: "/api/auth/logout", | 170 | logout: "/api/auth/logout", |
| @@ -162,13 +181,26 @@ const McpApprovalErrors = [ · 10 unmodified lines | |||
| 162 | /** Public auth endpoints — no authentication required */ | 181 | /** Public auth endpoints — no authentication required */ |
| 163 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") | 182 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") |
| 164 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) | 183 | .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) |
| 184 | // Sign-out is PUBLIC on purpose. The console posts it as a top-level form | ||
| 185 | // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which | ||
| 186 | // means an error response is rendered as the page — and a browser whose | ||
| 187 | // session has already ended is exactly the browser most likely to click it | ||
| 188 | // (a second tab, a re-submit from history, an expired or revoked session). | ||
| 189 | // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen | ||
| 190 | // instead of being signed out. There is nothing to authorize here anyway: | ||
| 191 | // the request can only end the session whose cookie it presents. | ||
| 192 | .add(HttpApiEndpoint.post("logout", "/auth/logout")) | ||
| 165 | .add( | 193 | .add( |
| 166 | HttpApiEndpoint.get("callback", "/auth/callback", { | 194 | HttpApiEndpoint.get("callback", "/auth/callback", { |
| 167 | query: AuthCallbackSearch, | 195 | query: AuthCallbackSearch, |
| 168 | error: AuthErrors, | 196 | error: AuthErrors, |
| 169 | }), | 197 | }), |
| 170 | ) | 198 | ) |
| 171 | .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {} | 199 | .add( |
| 200 | HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { | ||
| 201 | success: CliLoginResponse, | ||
| 202 | }), | ||
| 203 | ) {} | ||
| 172 | 204 | ||
| 173 | /** Session auth endpoints — require a logged-in user, may not have an org */ | 205 | /** Session auth endpoints — require a logged-in user, may not have an org */ |
| 174 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") | 206 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") |
| @@ -178,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 3 unmodified lines | |||
| 178 | error: AuthErrors, | 210 | error: AuthErrors, |
| 179 | }), | 211 | }), |
| 180 | ) | 212 | ) |
| 181 | .add(HttpApiEndpoint.post("logout", "/auth/logout")) | ||
| 182 | .add( | 213 | .add( |
| 183 | HttpApiEndpoint.get("organizations", "/auth/organizations", { | 214 | HttpApiEndpoint.get("organizations", "/auth/organizations", { |
| 184 | success: AuthOrganizationsResponse, | 215 | success: AuthOrganizationsResponse, |
| @@ -192,6 +223,13 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 7 unmodified lines | |||
| 192 | error: AuthErrors, | 223 | error: AuthErrors, |
| 193 | }), | 224 | }), |
| 194 | ) | 225 | ) |
| 226 | .add( | ||
| 227 | HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", { | ||
| 228 | payload: DeleteOrganizationBody, | ||
| 229 | success: DeleteOrganizationResponse, | ||
| 230 | error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden], | ||
| 231 | }), | ||
| 232 | ) | ||
| 195 | .add( | 233 | .add( |
| 196 | HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", { | 234 | HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", { |
| 197 | success: PendingInvitationsResponse, | 235 | success: PendingInvitationsResponse, |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { makeUserStore } from "../auth/user-store"; | 2 | import { makeUserStore } from "../auth/user-store"; |
| 3 | import { DbService } from "../db/db"; | 3 | import { DbService } from "../db/db"; |
| 4 | import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; | 4 | import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors"; |
| 5 | 5 | ||
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect | 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect |
| @@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 1 unmodified line | |||
| 9 | 9 | ||
| 10 | type RawStore = ReturnType<typeof makeUserStore>; | 10 | type RawStore = ReturnType<typeof makeUserStore>; |
| 11 | 11 | ||
| 12 | // `op` names the store call so every span reads `user_store.<operation>` | ||
| 13 | // instead of one undifferentiated "user_store" bucket, failures log which | ||
| 14 | // query actually failed, and — because the same `op` is threaded onto the | ||
| 15 | // public error alongside the classified driver reason — an error report is | ||
| 16 | // diagnosable without the trace. | ||
| 12 | const makeService = (store: RawStore) => ({ | 17 | const makeService = (store: RawStore) => ({ |
| 13 | use: <A>(fn: (s: RawStore) => Promise<A>) => | 18 | use: <A>(op: string, fn: (s: RawStore) => Promise<A>) => |
| 14 | withServiceLogging( | 19 | withServiceLogging( |
| 15 | "user_store", | 20 | `user_store.${op}`, |
| 16 | () => new UserStoreError(), | 21 | (failure) => userStoreErrorFromFailure(op, failure), |
| 17 | tryPromiseService(() => fn(store)), | 22 | tryPromiseService(() => fn(store)), |
| 18 | ), | 23 | ), |
| 19 | }); | 24 | }); |
| @@ -1,19 +1,23 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // SSR auth gate — the server-side session check for DOCUMENT requests. | 2 | // Document auth gate — the server-side session check for DOCUMENT requests. |
| 3 | // | 3 | // |
| 4 | // The sealed `wos-session` cookie is verified right here in the worker | 4 | // The sealed `wos-session` cookie is verified right here in the worker |
| 5 | // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip | 5 | // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip |
| 6 | // except token refresh), so by the time the SPA is served the server KNOWS | 6 | // except token refresh), so by the time the SPA shell is served the server |
| 7 | // who it's serving: | 7 | // KNOWS who it's serving: |
| 8 | // | 8 | // |
| 9 | // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists | 9 | // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists |
| 10 | // - org-less → 302 /create-org (onboarding owns those sessions) | 10 | // - org-less → 302 /create-org (onboarding owns those sessions) |
| 11 | // - signed in → the document is served WITH the verified identity: the | 11 | // - signed in → the prerendered shell is served, and the auth-hint cookie is |
| 12 | // auth-hint travels to the SSR render via request-middleware context (the | 12 | // minted when the browser doesn't hold a current one — the client seeds its |
| 13 | // root loader picks it up), and is minted as a cookie when the browser | 13 | // auth state from that cookie right after mount (there is no per-request |
| 14 | // doesn't hold a current one — so the very first paint is the real app | 14 | // render to dehydrate it into). The hint is display-only; /account/me |
| 15 | // shell, never a skeleton. The hint is display-only; /account/me remains | 15 | // remains the authority and the client keeps it fresh from then on. |
| 16 | // the authority and the client keeps it fresh from then on. | 16 | // |
| 17 | // Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS | ||
| 18 | // refresh tokens are single-use, and a verify that rotates the sealed session | ||
| 19 | // must deliver the new cookie to the browser or the next expiry logs the user | ||
| 20 | // out. Document navigations are where that rotation lands. | ||
| 17 | // | 21 | // |
| 18 | // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / | 22 | // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / |
| 19 | // accept), excluding app-owned paths (/api, /mcp — they answer for themselves | 23 | // accept), excluding app-owned paths (/api, /mcp — they answer for themselves |
| @@ -23,6 +27,7 @@ · 3 unmodified lines | |||
| 23 | import { createMiddleware } from "@tanstack/react-start"; | 27 | import { createMiddleware } from "@tanstack/react-start"; |
| 24 | import { Effect, Exit, Layer, ManagedRuntime } from "effect"; | 28 | import { Effect, Exit, Layer, ManagedRuntime } from "effect"; |
| 25 | 29 | ||
| 30 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 26 | import { | 31 | import { |
| 27 | AUTH_HINT_COOKIE, | 32 | AUTH_HINT_COOKIE, |
| 28 | AUTH_HINT_MAX_AGE_SECONDS, | 33 | AUTH_HINT_MAX_AGE_SECONDS, |
| @@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; · 6 unmodified lines | |||
| 35 | import { makeDbLayer } from "../db/db"; | 40 | import { makeDbLayer } from "../db/db"; |
| 36 | import { makeUserStoreLayer, UserStoreService } from "./context"; | 41 | import { makeUserStoreLayer, UserStoreService } from "./context"; |
| 37 | import { parseCookie } from "./cookies"; | 42 | import { parseCookie } from "./cookies"; |
| 43 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; | ||
| 38 | import { sealedSessionDisplayName } from "./middleware"; | 44 | import { sealedSessionDisplayName } from "./middleware"; |
| 39 | import { browserOriginFromRequest } from "./request-origin"; | 45 | import { authorizeOrganizationSelector } from "./organization"; |
| 40 | import { loginPath, safeReturnTo } from "./return-to"; | 46 | import { loginPath, safeReturnTo } from "./return-to"; |
| 41 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; | 47 | import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; |
| 42 | import { WorkOSClient } from "./workos"; | 48 | import { WorkOSClient } from "./workos"; |
| @@ -150,7 +156,7 @@ const organizationDisplay = async ( · 107 unmodified lines | |||
| 150 | ): Promise<{ name: string; slug: string }> => { | 156 | ): Promise<{ name: string; slug: string }> => { |
| 151 | const exit = await getRuntime().runPromiseExit( | 157 | const exit = await getRuntime().runPromiseExit( |
| 152 | Effect.flatMap(UserStoreService.asEffect(), (users) => | 158 | Effect.flatMap(UserStoreService.asEffect(), (users) => |
| 153 | users.use((store) => store.getOrganization(organizationId)), | 159 | users.use("getOrganization", (store) => store.getOrganization(organizationId)), |
| 154 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), | 160 | ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), |
| 155 | ); | 161 | ); |
| 156 | return Exit.isSuccess(exit) | 162 | return Exit.isSuccess(exit) |
| @@ -158,6 +164,23 @@ const organizationDisplay = async ( · 1 unmodified line | |||
| 158 | : { name: "", slug: "" }; | 164 | : { name: "", slug: "" }; |
| 159 | }; | 165 | }; |
| 160 | 166 | ||
| 167 | // Live membership check for the last-org cookie's slug. Same authorize path | ||
| 168 | // as any org selector — the cookie is a preference, so a slug the user can't | ||
| 169 | // access (stale after removal/deletion, or forged) resolves to null and the | ||
| 170 | // bare path falls through to today's canonicalize-onto-session-org behavior. | ||
| 171 | // Per-request store layers for the same reason as organizationDisplay. | ||
| 172 | const authorizeLastOrgSlug = async ( | ||
| 173 | userId: string, | ||
| 174 | slug: string, | ||
| 175 | ): Promise<{ readonly id: string } | null> => { | ||
| 176 | const exit = await getRuntime().runPromiseExit( | ||
| 177 | authorizeOrganizationSelector(userId, slug).pipe( | ||
| 178 | Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | return Exit.isSuccess(exit) ? exit.value : null; | ||
| 182 | }; | ||
| 183 | |||
| 161 | const hintSetCookie = (hint: AuthHint) => | 184 | const hintSetCookie = (hint: AuthHint) => |
| 162 | `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; | 185 | `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; |
| 163 | 186 | ||
| @@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 62 unmodified lines | |||
| 226 | return redirect("/create-org", { refreshedSession: session.refreshedSession }); | 249 | return redirect("/create-org", { refreshedSession: session.refreshedSession }); |
| 227 | } | 250 | } |
| 228 | 251 | ||
| 229 | // Serve the document WITH the verified identity: the hint rides to the | 252 | // A BARE console path (no org slug in the URL) canonicalizes onto the org |
| 230 | // SSR render through middleware context (the root loader reads it), so | 253 | // this browser last worked in (the last-org cookie), not the org pinned in |
| 231 | // the server paints the real authenticated shell — no loading state, no | 254 | // the session at login time — a multi-org user re-entering at `/` lands |
| 232 | // skeleton. The request origin rides along too: it's what the connect | 255 | // where they left off. Slugged URLs never enter here (the URL names its |
| 233 | // card's MCP URL is built from, and the server knows it (the SPA only | 256 | // own scope), so this costs nothing on the steady state; the not-found |
| 234 | // learns `window.location.origin` after mount), so passing it here lets | 257 | // contract is untouched because an unknown-but-valid slug in the URL reads |
| 235 | // SSR render the real `https://…/<org>/mcp` instead of the client-side | 258 | // as slugged, not bare. When the cookie matches the session's own org (the |
| 236 | // `http://127.0.0.1:4000` default — which would otherwise flash until | 259 | // overwhelmingly common single-org case) the client-side OrgSlugGate |
| 237 | // hydration corrected it. Set-cookie writes ride on the rendered response. | 260 | // already canonicalizes onto it, so skip the live membership check and the |
| 261 | // redirect entirely. | ||
| 262 | const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE); | ||
| 263 | const firstSegment = pathname.split("/")[1] ?? ""; | ||
| 264 | if ( | ||
| 265 | session.organizationId && | ||
| 266 | lastOrgSlug && | ||
| 267 | isValidOrgSlug(lastOrgSlug) && | ||
| 268 | !isValidOrgSlug(firstSegment) && | ||
| 269 | !ONBOARDING_PATHS.has(pathname) | ||
| 270 | ) { | ||
| 271 | const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug; | ||
| 272 | if (lastOrgSlug !== sessionOrgSlug) { | ||
| 273 | // The cookie is a preference, not an authority: only redirect onto an | ||
| 274 | // org the caller actively belongs to (stale/forged values fall through | ||
| 275 | // to today's session-org canonicalization). | ||
| 276 | const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug); | ||
| 277 | if (lastOrg) { | ||
| 278 | return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, { | ||
| 279 | refreshedSession: session.refreshedSession, | ||
| 280 | }); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | // Serve the shell, minting the auth-hint cookie when the browser lacks a | ||
| 286 | // current one so the client's post-mount cookie read seeds the verified | ||
| 287 | // identity. Set-cookie writes ride on the shell response. | ||
| 238 | const { hint, mint } = await resolveAuthHint(session, cookieHeader); | 288 | const { hint, mint } = await resolveAuthHint(session, cookieHeader); |
| 239 | const result = await next({ | 289 | const result = await next(); |
| 240 | context: { authHint: hint, origin: browserOriginFromRequest(request) }, | ||
| 241 | }); | ||
| 242 | if (!mint && !session.refreshedSession) return result; | 290 | if (!mint && !session.refreshedSession) return result; |
| 243 | 291 | ||
| 244 | const response = new Response(result.response.body, result.response); | 292 | const response = new Response(result.response.body, result.response); |
| @@ -0,0 +1,68 @@ | |||
| 1 | // The WorkOS failure-classification boundary: the service adapter must thread | ||
| 2 | // the SDK exception's HTTP status onto the public `WorkOSError`, and the | ||
| 3 | // definitive-denial predicate must split 401/403/404 (WorkOS answered "no" — | ||
| 4 | // fail closed) from 429/5xx/no-status (transient — retryable). This split is | ||
| 5 | // what keeps a WorkOS blip from condemning MCP sessions AND a revoked key from | ||
| 6 | // being mistaken for a blip. | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import { | ||
| 9 | GenericServerException, | ||
| 10 | NotFoundException, | ||
| 11 | RateLimitExceededException, | ||
| 12 | UnauthorizedException, | ||
| 13 | } from "@workos-inc/node/worker"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | ServiceAdapterError, | ||
| 17 | isDefinitiveWorkOSDenial, | ||
| 18 | isDefinitiveWorkOSDenialStatus, | ||
| 19 | workosErrorFromFailure, | ||
| 20 | } from "./errors"; | ||
| 21 | |||
| 22 | describe("workosErrorFromFailure", () => { | ||
| 23 | it("reads the status off the real SDK exceptions through the adapter wrapper", () => { | ||
| 24 | const cases: ReadonlyArray<readonly [unknown, number]> = [ | ||
| 25 | [new UnauthorizedException("req_1"), 401], | ||
| 26 | [ | ||
| 27 | new NotFoundException({ code: "not_found", message: "gone", path: "/x", requestID: "r" }), | ||
| 28 | 404, | ||
| 29 | ], | ||
| 30 | [new RateLimitExceededException("slow down", "req_2", 1), 429], | ||
| 31 | [new GenericServerException(503, "upstream", {}, "req_3"), 503], | ||
| 32 | ]; | ||
| 33 | for (const [sdkError, status] of cases) { | ||
| 34 | const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: sdkError })); | ||
| 35 | expect(wrapped.status, `status for ${String(sdkError)}`).toBe(status); | ||
| 36 | } | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("leaves status unset for non-HTTP failures (network error, timeout)", () => { | ||
| 40 | // The shape fetch/undici raise on a network failure: an error value with a | ||
| 41 | // message and no HTTP status (statusless by construction, unlike the SDK's | ||
| 42 | // typed HTTP exceptions). | ||
| 43 | const networkFailure: unknown = { name: "TypeError", message: "fetch failed" }; | ||
| 44 | const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: networkFailure })); | ||
| 45 | expect(wrapped.status).toBeUndefined(); | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | describe("isDefinitiveWorkOSDenial", () => { | ||
| 50 | it("treats 401/403/404 as definitive and everything else as transient", () => { | ||
| 51 | expect(isDefinitiveWorkOSDenialStatus(401)).toBe(true); | ||
| 52 | expect(isDefinitiveWorkOSDenialStatus(403)).toBe(true); | ||
| 53 | expect(isDefinitiveWorkOSDenialStatus(404)).toBe(true); | ||
| 54 | expect(isDefinitiveWorkOSDenialStatus(429)).toBe(false); | ||
| 55 | expect(isDefinitiveWorkOSDenialStatus(500)).toBe(false); | ||
| 56 | expect(isDefinitiveWorkOSDenialStatus(503)).toBe(false); | ||
| 57 | expect(isDefinitiveWorkOSDenialStatus(undefined)).toBe(false); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("only claims WorkOSError instances, never arbitrary errors with a status", () => { | ||
| 61 | expect(isDefinitiveWorkOSDenial({ status: 403 })).toBe(false); | ||
| 62 | expect( | ||
| 63 | isDefinitiveWorkOSDenial( | ||
| 64 | workosErrorFromFailure(new ServiceAdapterError({ cause: new UnauthorizedException("r") })), | ||
| 65 | ), | ||
| 66 | ).toBe(true); | ||
| 67 | }); | ||
| 68 | }); | ||
| @@ -1,17 +1,169 @@ | |||
| 1 | import { Data, Effect, Schema } from "effect"; | 1 | import { Data, Effect, Option, Predicate, Schema } from "effect"; |
| 2 | 2 | ||
| 3 | // How a user-store call failed, classified from the driver cause. Safe to put | ||
| 4 | // on the wire and on a Sentry tag: it names a failure MODE, never a query, a | ||
| 5 | // value, or a customer. | ||
| 6 | export const USER_STORE_FAILURE_REASONS = [ | ||
| 7 | "connect_timeout", | ||
| 8 | "connection_closed", | ||
| 9 | "query", | ||
| 10 | "unknown", | ||
| 11 | ] as const; | ||
| 12 | |||
| 13 | export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number]; | ||
| 14 | |||
| 15 | /** | ||
| 16 | * The public failure of every cloud user-store call. | ||
| 17 | * | ||
| 18 | * It carries the two fields that make an issue diagnosable from the error | ||
| 19 | * alone: which store call failed, and how. Before those existed the error had | ||
| 20 | * an empty field set, so Sentry showed a titleless, messageless issue and the | ||
| 21 | * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is | ||
| 22 | * scrubbed server-side — the failing operation and the driver reason existed | ||
| 23 | * only in the trace store. Same shape as `WorkOSError.status`: a small, safe | ||
| 24 | * classification field threaded at the service boundary. | ||
| 25 | */ | ||
| 3 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( | 26 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( |
| 4 | "UserStoreError", | 27 | "UserStoreError", |
| 5 | {}, | 28 | { |
| 29 | /** The store call that failed, e.g. `getOrganization`. */ | ||
| 30 | operation: Schema.String, | ||
| 31 | /** How it failed, classified from the driver cause chain. */ | ||
| 32 | reason: Schema.Literals(USER_STORE_FAILURE_REASONS), | ||
| 33 | }, | ||
| 6 | { httpApiStatus: 500 }, | 34 | { httpApiStatus: 500 }, |
| 7 | ) {} | 35 | ) { |
| 36 | override get message(): string { | ||
| 37 | return `user store ${this.operation} failed: ${this.reason}`; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /** Reasons a retry can plausibly clear: the query never reached a healthy | ||
| 42 | * server. A `query` failure is deterministic and must not be retried. */ | ||
| 43 | export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean => | ||
| 44 | reason === "connect_timeout" || reason === "connection_closed"; | ||
| 45 | |||
| 46 | // postgres.js tags its connection failures with a string `code` | ||
| 47 | // (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the | ||
| 48 | // SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with | ||
| 49 | // the driver error in `.cause`, and the service adapter wraps that again, so | ||
| 50 | // the classification walks the chain rather than inspecting one level. | ||
| 51 | const MAX_CAUSE_DEPTH = 8; | ||
| 52 | |||
| 53 | const REASON_BY_DRIVER_CODE: Readonly<Record<string, UserStoreFailureReason>> = { | ||
| 54 | CONNECT_TIMEOUT: "connect_timeout", | ||
| 55 | ETIMEDOUT: "connect_timeout", | ||
| 56 | CONNECTION_CLOSED: "connection_closed", | ||
| 57 | CONNECTION_ENDED: "connection_closed", | ||
| 58 | CONNECTION_DESTROYED: "connection_closed", | ||
| 59 | ECONNREFUSED: "connection_closed", | ||
| 60 | ECONNRESET: "connection_closed", | ||
| 61 | }; | ||
| 62 | |||
| 63 | const stringCodeOf = (value: unknown): string | undefined => { | ||
| 64 | if (typeof value !== "object" || value === null) return undefined; | ||
| 65 | const code = (value as { readonly code?: unknown }).code; | ||
| 66 | return typeof code === "string" ? code : undefined; | ||
| 67 | }; | ||
| 68 | |||
| 69 | const driverCodesOf = (failure: unknown): readonly string[] => { | ||
| 70 | const codes: string[] = []; | ||
| 71 | let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure; | ||
| 72 | for ( | ||
| 73 | let depth = 0; | ||
| 74 | depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; | ||
| 75 | depth++ | ||
| 76 | ) { | ||
| 77 | const code = stringCodeOf(current); | ||
| 78 | if (code !== undefined) codes.push(code); | ||
| 79 | current = | ||
| 80 | typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined; | ||
| 81 | } | ||
| 82 | return codes; | ||
| 83 | }; | ||
| 84 | |||
| 85 | /** Classify a raw store failure. A recognised connection code wins; any other | ||
| 86 | * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is | ||
| 87 | * `unknown`. */ | ||
| 88 | export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => { | ||
| 89 | const codes = driverCodesOf(failure); | ||
| 90 | for (const code of codes) { | ||
| 91 | const reason = REASON_BY_DRIVER_CODE[code]; | ||
| 92 | if (reason !== undefined) return reason; | ||
| 93 | } | ||
| 94 | return codes.length > 0 ? "query" : "unknown"; | ||
| 95 | }; | ||
| 96 | |||
| 97 | /** Build the public `UserStoreError` for a store-adapter failure, naming the | ||
| 98 | * operation the call site already knows and classifying the driver cause. */ | ||
| 99 | export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError => | ||
| 100 | new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) }); | ||
| 8 | 101 | ||
| 9 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( | 102 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( |
| 10 | "WorkOSError", | 103 | "WorkOSError", |
| 11 | {}, | 104 | { |
| 105 | /** | ||
| 106 | * The upstream HTTP status WorkOS answered with, when the failure WAS an | ||
| 107 | * HTTP answer (the SDK sets `.status` on all its typed exceptions). | ||
| 108 | * Absent for network errors / timeouts. Consumers that must distinguish | ||
| 109 | * "WorkOS said no" (definitive 4xx) from "WorkOS was unreachable" | ||
| 110 | * (transient) branch on this — see `isDefinitiveWorkOSDenialStatus`. | ||
| 111 | */ | ||
| 112 | status: Schema.optional(Schema.Number), | ||
| 113 | }, | ||
| 12 | { httpApiStatus: 500 }, | 114 | { httpApiStatus: 500 }, |
| 13 | ) {} | 115 | ) {} |
| 14 | 116 | ||
| 117 | // Statuses that are a DEFINITIVE denial from WorkOS — a deterministic "this | ||
| 118 | // request cannot be authorized" answer (invalid/revoked API key, forbidden, | ||
| 119 | // resource gone), not a blip. Retrying does not help, and auth decisions built | ||
| 120 | // on them must fail CLOSED. Everything else (429, 5xx, or no status at all — | ||
| 121 | // network error/timeout) is transient and must stay retryable. | ||
| 122 | const DEFINITIVE_DENIAL_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]); | ||
| 123 | |||
| 124 | export const isDefinitiveWorkOSDenialStatus = (status: number | undefined): boolean => | ||
| 125 | status !== undefined && DEFINITIVE_DENIAL_STATUSES.has(status); | ||
| 126 | |||
| 127 | // Tag-based guards (same pattern as `isOAuth2Error` in core/sdk/oauth-helpers): | ||
| 128 | // the untyped failure channels these run against carry values that crossed | ||
| 129 | // layer boundaries, so the discriminant is the `_tag`, not the prototype chain. | ||
| 130 | const isWorkOSError = Predicate.isTagged("WorkOSError") as (error: unknown) => error is WorkOSError; | ||
| 131 | |||
| 132 | /** A `WorkOSError` that is a definitive denial (see above), typed on `unknown` | ||
| 133 | * so auth code holding an untyped failure channel can branch safely. */ | ||
| 134 | export const isDefinitiveWorkOSDenial = (error: unknown): boolean => | ||
| 135 | isWorkOSError(error) && isDefinitiveWorkOSDenialStatus(error.status); | ||
| 136 | |||
| 137 | // Every typed exception the WorkOS node SDK throws for an HTTP answer carries a | ||
| 138 | // numeric `.status` (UnauthorizedException 401, NotFoundException 404, | ||
| 139 | // RateLimitExceededException 429, GenericServerException/OauthException with the | ||
| 140 | // live status, ...). Network errors and timeouts throw non-SDK errors with no | ||
| 141 | // status. Same extraction pattern as the workos-vault plugin's | ||
| 142 | // `statusFromWorkOSCause` (packages/plugins/workos-vault/src/sdk/client.ts). | ||
| 143 | const CauseWithStatusSchema = Schema.Struct({ status: Schema.Number }); | ||
| 144 | const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema); | ||
| 145 | |||
| 146 | export const statusFromWorkOSCause = (cause: unknown): number | undefined => | ||
| 147 | Option.match(decodeCauseWithStatusOption(cause), { | ||
| 148 | onNone: () => undefined, | ||
| 149 | onSome: (decoded) => decoded.status, | ||
| 150 | }); | ||
| 151 | |||
| 152 | /** | ||
| 153 | * Build the public `WorkOSError` for a WorkOS service-adapter failure, | ||
| 154 | * threading the upstream HTTP status through when the underlying SDK exception | ||
| 155 | * carried one. The failure is normally the `ServiceAdapterError` wrapper from | ||
| 156 | * `tryPromiseService` (SDK exception in `.cause`); a bare cause also works. | ||
| 157 | */ | ||
| 158 | const isServiceAdapterError = Predicate.isTagged("ServiceAdapterError") as ( | ||
| 159 | failure: unknown, | ||
| 160 | ) => failure is ServiceAdapterError; | ||
| 161 | |||
| 162 | export const workosErrorFromFailure = (failure: unknown): WorkOSError => | ||
| 163 | new WorkOSError({ | ||
| 164 | status: statusFromWorkOSCause(isServiceAdapterError(failure) ? failure.cause : failure), | ||
| 165 | }); | ||
| 166 | |||
| 15 | export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()( | 167 | export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()( |
| 16 | "ApiKeyManagementError", | 168 | "ApiKeyManagementError", |
| 17 | { cause: Schema.Unknown }, | 169 | { cause: Schema.Unknown }, |
| @@ -47,7 +199,11 @@ export const tryPromiseService = <A>(fn: () => Promise<A>): Effect.Effect<A, Ser · 29 unmodified lines | |||
| 47 | */ | 199 | */ |
| 48 | export const withServiceLogging = <A, E, R>( | 200 | export const withServiceLogging = <A, E, R>( |
| 49 | name: string, | 201 | name: string, |
| 50 | publicError: () => E, | 202 | // Receives the raw failure (for service adapters: the `ServiceAdapterError` |
| 203 | // whose `.cause` is the SDK exception) so the public error can carry safe | ||
| 204 | // classification fields (e.g. `WorkOSError.status`). Zero-arg callers that | ||
| 205 | // ignore the failure keep working unchanged. | ||
| 206 | publicError: (failure: unknown) => E, | ||
| 51 | effect: Effect.Effect<A, unknown, R>, | 207 | effect: Effect.Effect<A, unknown, R>, |
| 52 | ): Effect.Effect<A, E, R> => | 208 | ): Effect.Effect<A, E, R> => |
| 53 | effect.pipe( | 209 | effect.pipe( |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | CloudAuthPublicApi, | 9 | CloudAuthPublicApi, |
| 10 | McpExecutionNotFoundError, | 10 | McpExecutionNotFoundError, |
| 11 | McpSessionForbiddenError, | 11 | McpSessionForbiddenError, |
| 12 | OrganizationDeletionForbidden, | ||
| 12 | } from "./api"; | 13 | } from "./api"; |
| 13 | import { NoOrganization } from "@executor-js/api/server"; | 14 | import { NoOrganization } from "@executor-js/api/server"; |
| 14 | // Pure constants/codec module (no React) — safe in the backend graph. | 15 | // Pure constants/codec module (no React) — safe in the backend graph. |
| @@ -21,11 +22,14 @@ import { env } from "cloudflare:workers"; · 6 unmodified lines | |||
| 21 | import { WorkOSError } from "./errors"; | 22 | import { WorkOSError } from "./errors"; |
| 22 | import { WorkOSClient } from "./workos"; | 23 | import { WorkOSClient } from "./workos"; |
| 23 | import { AutumnService } from "../extensions/billing/service"; | 24 | import { AutumnService } from "../extensions/billing/service"; |
| 25 | import { forkReportMemberSeats } from "../extensions/billing/member-seats"; | ||
| 26 | import { captureCauseEffect } from "../observability"; | ||
| 24 | import { | 27 | import { |
| 25 | hasPaidOrganizationSubscription, | 28 | hasPaidOrganizationSubscription, |
| 26 | isOverFreeOrganizationLimit, | 29 | isOverFreeOrganizationLimit, |
| 27 | shouldApplyFreeOrganizationLimit, | 30 | shouldApplyFreeOrganizationLimit, |
| 28 | } from "../extensions/billing/plans"; | 31 | } from "../extensions/billing/plans"; |
| 32 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; | ||
| 29 | import { | 33 | import { |
| 30 | ORG_SELECTOR_HEADER, | 34 | ORG_SELECTOR_HEADER, |
| 31 | authorizeOrganization, | 35 | authorizeOrganization, |
| @@ -142,8 +146,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n · 110 unmodified lines | |||
| 142 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); | 146 | HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); |
| 143 | 147 | ||
| 144 | // --------------------------------------------------------------------------- | 148 | // --------------------------------------------------------------------------- |
| 145 | // Single non-protected API surface — public (login/callback) + session | 149 | // Single non-protected API surface — public (login/callback/logout) + session |
| 146 | // (me/logout/organizations/switch-organization). The session group has SessionAuth on it. | 150 | // (me/organizations/switch-organization). The session group has SessionAuth on it. |
| 147 | // --------------------------------------------------------------------------- | 151 | // --------------------------------------------------------------------------- |
| 148 | 152 | ||
| 149 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); | 153 | export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); |
| @@ -200,7 +204,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 50 unmodified lines | |||
| 200 | const result = yield* workos.authenticateWithCode(query.code); | 204 | const result = yield* workos.authenticateWithCode(query.code); |
| 201 | 205 | ||
| 202 | // Mirror the account locally | 206 | // Mirror the account locally |
| 203 | yield* users.use((s) => s.ensureAccount(result.user.id)); | 207 | yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id)); |
| 204 | 208 | ||
| 205 | let sealedSession = result.sealedSession; | 209 | let sealedSession = result.sealedSession; |
| 206 | 210 | ||
| @@ -217,17 +221,40 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 10 unmodified lines | |||
| 217 | : null; | 221 | : null; |
| 218 | 222 | ||
| 219 | // Prefer the org in the URL that sent the user to login. If the URL | 223 | // Prefer the org in the URL that sent the user to login. If the URL |
| 220 | // is bare, or not an org route, fall back to WorkOS's org and then to | 224 | // is bare, or not an org route, prefer the org this browser last |
| 221 | // the first active membership for org-less sessions. Pending | 225 | // worked in (the last-org cookie — it outlives the session precisely |
| 222 | // memberships are skipped because refreshing into one 400s and would | 226 | // so a fresh login lands where the user left off), then WorkOS's |
| 223 | // bypass invite consent. | 227 | // org, then the first active membership for org-less sessions. |
| 224 | let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null; | 228 | // Pending memberships are skipped because refreshing into one 400s |
| 229 | // and would bypass invite consent. The cookie is membership-checked | ||
| 230 | // like any selector, so a stale one just falls through. | ||
| 231 | let targetOrganizationId = requestedOrg?.id ?? null; | ||
| 232 | if (!targetOrganizationId && !requestedOrgSelector) { | ||
| 233 | const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; | ||
| 234 | const lastOrg = | ||
| 235 | lastOrgSlug && isValidOrgSlug(lastOrgSlug) | ||
| 236 | ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe( | ||
| 237 | Effect.orElseSucceed(() => null), | ||
| 238 | ) | ||
| 239 | : null; | ||
| 240 | targetOrganizationId = lastOrg?.id ?? null; | ||
| 241 | } | ||
| 242 | targetOrganizationId ??= result.organizationId ?? null; | ||
| 225 | if (!targetOrganizationId && !requestedOrgSelector) { | 243 | if (!targetOrganizationId && !requestedOrgSelector) { |
| 226 | const memberships = yield* workos.listUserMemberships(result.user.id); | 244 | const memberships = yield* workos.listUserMemberships(result.user.id); |
| 227 | const existingActive = memberships.data.find((m) => m.status === "active"); | 245 | const existingActive = memberships.data.find((m) => m.status === "active"); |
| 228 | targetOrganizationId = existingActive?.organizationId ?? null; | 246 | targetOrganizationId = existingActive?.organizationId ?? null; |
| 229 | } | 247 | } |
| 230 | 248 | ||
| 249 | // Seat changes the app never sees a mutation for (invitation | ||
| 250 | // acceptance in AuthKit, SSO JIT provisioning, join by domain, | ||
| 251 | // WorkOS dashboard edits) all end in a sign-in, so every login | ||
| 252 | // reconciles the landed org's billed seat count. Forked: billing | ||
| 253 | // must not delay the login. | ||
| 254 | if (targetOrganizationId) { | ||
| 255 | yield* forkReportMemberSeats(targetOrganizationId); | ||
| 256 | } | ||
| 257 | |||
| 231 | if ( | 258 | if ( |
| 232 | targetOrganizationId && | 259 | targetOrganizationId && |
| 233 | targetOrganizationId !== result.organizationId && | 260 | targetOrganizationId !== result.organizationId && |
| @@ -242,7 +269,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 8 unmodified lines | |||
| 242 | } | 269 | } |
| 243 | 270 | ||
| 244 | if (!sealedSession) { | 271 | if (!sealedSession) { |
| 245 | return HttpServerResponse.text("Failed to create session", { status: 500 }); | 272 | return HttpServerResponse.text("Failed to create session", { |
| 273 | status: 500, | ||
| 274 | }); | ||
| 246 | } | 275 | } |
| 247 | 276 | ||
| 248 | return deleteResponseCookie( | 277 | return deleteResponseCookie( |
| @@ -256,6 +285,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 7 unmodified lines | |||
| 256 | ); | 285 | ); |
| 257 | }), | 286 | }), |
| 258 | ) | 287 | ) |
| 288 | .handleRaw("logout", ({ request }) => | ||
| 289 | Effect.gen(function* () { | ||
| 290 | const workos = yield* WorkOSClient; | ||
| 291 | // The session this browser presents, NOT one the middleware vouched | ||
| 292 | // for — signing out of a session that has already ended must still | ||
| 293 | // sign the browser out (see the group declaration in ./api.ts). | ||
| 294 | const sealedSession = request.cookies["wos-session"] ?? ""; | ||
| 295 | |||
| 296 | // WorkOS's documented sign-out: send the browser through the WorkOS | ||
| 297 | // logout endpoint, which ends the AuthKit session upstream and then | ||
| 298 | // redirects to the registered sign-out URL. Without this hop, the | ||
| 299 | // hosted session survives and the next "Sign in" silently | ||
| 300 | // re-authenticates (issue #1445). Fail-open when the cookie won't | ||
| 301 | // unseal — there is then nothing to end upstream, and local sign-out | ||
| 302 | // must still complete, so fall back to "/". | ||
| 303 | const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | ||
| 304 | const logoutUrl = sealedSession | ||
| 305 | ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) | ||
| 306 | : null; | ||
| 307 | |||
| 308 | const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); | ||
| 309 | |||
| 310 | // Drop only what this browser actually presented. Both cookies are | ||
| 311 | // SameSite=Lax, so a cross-site form POST carries neither — it gets | ||
| 312 | // the bare redirect and cannot be used to sign anyone out. | ||
| 313 | if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; | ||
| 314 | |||
| 315 | // The auth-hint travels with the session: leaving it behind would | ||
| 316 | // make the next page load optimistically paint the app shell for a | ||
| 317 | // signed-out browser. | ||
| 318 | return deleteResponseCookie( | ||
| 319 | deleteResponseCookie(response, "wos-session"), | ||
| 320 | AUTH_HINT_COOKIE, | ||
| 321 | ); | ||
| 322 | }), | ||
| 323 | ) | ||
| 259 | // CLI device-login discovery. The WorkOS device endpoints live on the | 324 | // CLI device-login discovery. The WorkOS device endpoints live on the |
| 260 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, | 325 | // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, |
| 261 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with | 326 | // the SAME base the SDK uses, so e2e points the CLI at the emulator with |
| @@ -301,17 +366,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 39 unmodified lines | |||
| 301 | }; | 366 | }; |
| 302 | }), | 367 | }), |
| 303 | ) | 368 | ) |
| 304 | .handleRaw("logout", () => | ||
| 305 | Effect.succeed( | ||
| 306 | // The auth-hint travels with the session: leaving it behind would | ||
| 307 | // make the next page load optimistically paint the app shell for a | ||
| 308 | // signed-out browser. | ||
| 309 | deleteResponseCookie( | ||
| 310 | deleteResponseCookie(HttpServerResponse.redirect("/", { status: 302 }), "wos-session"), | ||
| 311 | AUTH_HINT_COOKIE, | ||
| 312 | ), | ||
| 313 | ), | ||
| 314 | ) | ||
| 315 | .handle("organizations", () => | 369 | .handle("organizations", () => |
| 316 | Effect.gen(function* () { | 370 | Effect.gen(function* () { |
| 317 | const workos = yield* WorkOSClient; | 371 | const workos = yield* WorkOSClient; |
| @@ -323,7 +377,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines | |||
| 323 | const organizations = yield* Effect.all( | 377 | const organizations = yield* Effect.all( |
| 324 | memberships.data.map((m) => | 378 | memberships.data.map((m) => |
| 325 | resolveOrganization(m.organizationId).pipe( | 379 | resolveOrganization(m.organizationId).pipe( |
| 326 | Effect.map((org) => ({ id: org.id, name: org.name, slug: org.slug })), | 380 | Effect.map((org) => ({ |
| 381 | id: org.id, | ||
| 382 | name: org.name, | ||
| 383 | slug: org.slug, | ||
| 384 | })), | ||
| 327 | Effect.orElseSucceed(() => null), | 385 | Effect.orElseSucceed(() => null), |
| 328 | ), | 386 | ), |
| 329 | ), | 387 | ), |
| @@ -354,7 +412,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 24 unmodified lines | |||
| 354 | activeMemberships.map((membership) => | 412 | activeMemberships.map((membership) => |
| 355 | autumn | 413 | autumn |
| 356 | .use((client) => | 414 | .use((client) => |
| 357 | client.customers.getOrCreate({ customerId: membership.organizationId }), | 415 | client.customers.getOrCreate({ |
| 416 | customerId: membership.organizationId, | ||
| 417 | }), | ||
| 358 | ) | 418 | ) |
| 359 | .pipe( | 419 | .pipe( |
| 360 | Effect.map((customer) => | 420 | Effect.map((customer) => |
| @@ -366,7 +426,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines | |||
| 366 | ), | 426 | ), |
| 367 | { concurrency: 3 }, | 427 | { concurrency: 3 }, |
| 368 | ).pipe( | 428 | ).pipe( |
| 369 | Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), | 429 | // Any Autumn failure here (outage or missing customer) leaves the |
| 430 | // paid/free split unknown, and the limit must fail closed. | ||
| 431 | Effect.mapError(() => new WorkOSError()), | ||
| 370 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), | 432 | Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), |
| 371 | ); | 433 | ); |
| 372 | 434 | ||
| @@ -378,10 +440,30 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines | |||
| 378 | const org = yield* workos.createOrganization(name); | 440 | const org = yield* workos.createOrganization(name); |
| 379 | yield* workos.createMembership(org.id, session.accountId, "admin"); | 441 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 380 | // `upsertOrganization` mints the slug at insert — no separate heal step. | 442 | // `upsertOrganization` mints the slug at insert — no separate heal step. |
| 381 | const mirrored = yield* users.use((s) => | 443 | const mirrored = yield* users.use("upsertOrganization", (s) => |
| 382 | s.upsertOrganization({ id: org.id, name: org.name }), | 444 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 383 | ); | 445 | ); |
| 384 | 446 | ||
| 447 | // Provision the org's billing customer while we're the ones creating | ||
| 448 | // the org. Without this the first billing call an org ever makes is a | ||
| 449 | // non-creating one (balance check / usage track), which 404s and keeps | ||
| 450 | // 404ing — unlimited unbilled executions. Non-fatal: a billing blip | ||
| 451 | // must not block signup, and the billing seam heals a customer that | ||
| 452 | // is still missing later. | ||
| 453 | yield* autumn.ensureCustomer(org.id).pipe( | ||
| 454 | Effect.catch((error) => | ||
| 455 | Effect.gen(function* () { | ||
| 456 | yield* Effect.logWarning( | ||
| 457 | "createOrganization: could not provision the Autumn customer", | ||
| 458 | { organizationId: org.id, error }, | ||
| 459 | ); | ||
| 460 | yield* captureCauseEffect(error); | ||
| 461 | }), | ||
| 462 | ), | ||
| 463 | ); | ||
| 464 | // Seed the new org's billed seat count (the creator's seat). | ||
| 465 | yield* forkReportMemberSeats(org.id); | ||
| 466 | |||
| 385 | // Try to attach the new org to the current session. This can fail | 467 | // Try to attach the new org to the current session. This can fail |
| 386 | // (or silently return a session still scoped to the old org) when | 468 | // (or silently return a session still scoped to the old org) when |
| 387 | // the caller's current session is stale — most commonly after the | 469 | // the caller's current session is stale — most commonly after the |
| @@ -410,6 +492,80 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 22 unmodified lines | |||
| 410 | return { id: org.id, name: org.name, slug: mirrored.slug }; | 492 | return { id: org.id, name: org.name, slug: mirrored.slug }; |
| 411 | }), | 493 | }), |
| 412 | ) | 494 | ) |
| 495 | .handle("deleteOrganization", ({ payload }) => | ||
| 496 | Effect.gen(function* () { | ||
| 497 | const workos = yield* WorkOSClient; | ||
| 498 | const users = yield* UserStoreService; | ||
| 499 | const autumn = yield* AutumnService; | ||
| 500 | |||
| 501 | // Target the caller's currently-selected org (honors the org-selector | ||
| 502 | // header, same as the other org-scoped auth handlers). NoOrganization | ||
| 503 | // when the session has no org to act on. | ||
| 504 | const session = yield* requireSelectedOrganization; | ||
| 505 | const organizationId = session.organizationId; | ||
| 506 | |||
| 507 | // Admin-only. Live WorkOS check so a member removed/demoted moments | ||
| 508 | // ago can't delete the workspace. A pending admin invite is not an | ||
| 509 | // active admin, so require active status too. | ||
| 510 | const membership = yield* workos.getUserOrgMembership(organizationId, session.accountId); | ||
| 511 | if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { | ||
| 512 | return yield* new OrganizationDeletionForbidden(); | ||
| 513 | } | ||
| 514 | |||
| 515 | // The typed confirmation must match the org's current name — the same | ||
| 516 | // label the settings page shows. Trimmed on both sides. | ||
| 517 | const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); | ||
| 518 | if (!org || payload.confirmName.trim() !== org.name.trim()) { | ||
| 519 | return yield* new OrganizationDeletionForbidden(); | ||
| 520 | } | ||
| 521 | |||
| 522 | // WorkOS FIRST. Once the org is gone there, membership authorization | ||
| 523 | // fails for every member, so the workspace is truly deleted even if a | ||
| 524 | // later local step lags (leftover local rows become unreachable, not | ||
| 525 | // user-visible). The reverse order risks the org resurrecting as an | ||
| 526 | // empty workspace when a later request re-mirrors it with a new slug. | ||
| 527 | yield* workos.deleteOrganization(organizationId); | ||
| 528 | |||
| 529 | // Purge all local tenant data, secrets, and the identity mirror | ||
| 530 | // (cascades local memberships) in one transaction. If this fails | ||
| 531 | // after the WorkOS delete already succeeded, the org is gone for | ||
| 532 | // everyone (unreachable) but its secrets/tenant rows linger orphaned — | ||
| 533 | // alert loudly so that window gets swept, then surface the failure. | ||
| 534 | yield* users | ||
| 535 | .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) | ||
| 536 | .pipe( | ||
| 537 | Effect.tapError((error) => | ||
| 538 | Effect.logError( | ||
| 539 | "deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned", | ||
| 540 | { organizationId, error }, | ||
| 541 | ), | ||
| 542 | ), | ||
| 543 | ); | ||
| 544 | |||
| 545 | // Cancel billing. Best-effort: the org is already deleted, so a | ||
| 546 | // lingering Autumn customer is a billing loose end (log loudly) rather | ||
| 547 | // than a correctness failure that should 500 the caller. | ||
| 548 | yield* autumn | ||
| 549 | .use((client) => client.customers.delete({ customerId: organizationId })) | ||
| 550 | .pipe( | ||
| 551 | // Includes the "customer never existed" answer: nothing to cancel | ||
| 552 | // is a fine outcome for a deleted org, and it is still worth a line. | ||
| 553 | Effect.catch((error) => | ||
| 554 | Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { | ||
| 555 | organizationId, | ||
| 556 | error, | ||
| 557 | }), | ||
| 558 | ), | ||
| 559 | ); | ||
| 560 | |||
| 561 | // The caller's session is pinned to the now-deleted org — clear it so | ||
| 562 | // the browser bounces to login and rehydrates to another membership | ||
| 563 | // (or the create-org screen when they have none left). | ||
| 564 | (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); | ||
| 565 | |||
| 566 | return { success: true }; | ||
| 567 | }), | ||
| 568 | ) | ||
| 413 | .handle("pendingInvitations", () => | 569 | .handle("pendingInvitations", () => |
| 414 | Effect.gen(function* () { | 570 | Effect.gen(function* () { |
| 415 | const workos = yield* WorkOSClient; | 571 | const workos = yield* WorkOSClient; |
| @@ -479,10 +635,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 63 unmodified lines | |||
| 479 | // Mirror the org locally so domain tables can FK against it; the | 635 | // Mirror the org locally so domain tables can FK against it; the |
| 480 | // upsert mints the slug at insert — no separate heal step. | 636 | // upsert mints the slug at insert — no separate heal step. |
| 481 | const org = yield* workos.getOrganization(invitation.organizationId); | 637 | const org = yield* workos.getOrganization(invitation.organizationId); |
| 482 | const mirrored = yield* users.use((s) => | 638 | const mirrored = yield* users.use("upsertOrganization", (s) => |
| 483 | s.upsertOrganization({ id: org.id, name: org.name }), | 639 | s.upsertOrganization({ id: org.id, name: org.name }), |
| 484 | ); | 640 | ); |
| 485 | 641 | ||
| 642 | // The membership is active in WorkOS from this point even if | ||
| 643 | // attaching the session below fails, so reconcile the org's billed | ||
| 644 | // seat count now. | ||
| 645 | yield* forkReportMemberSeats(org.id); | ||
| 646 | |||
| 486 | // Attach the just-accepted org to the current session. Same shape | 647 | // Attach the just-accepted org to the current session. Same shape |
| 487 | // as createOrganization: refresh + verify; if we can't pin the | 648 | // as createOrganization: refresh + verify; if we can't pin the |
| 488 | // session in-place, clear the cookie and let the user bounce | 649 | // session in-place, clear the cookie and let the user bounce |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type KeyLike, | 9 | type KeyLike, |
| 10 | } from "jose"; | 10 | } from "jose"; |
| 11 | 11 | ||
| 12 | import { createCachedRemoteJWKSet } from "./jwks-cache"; | 12 | import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; |
| 13 | 13 | ||
| 14 | const issuer = "https://test-authkit.example.com"; | 14 | const issuer = "https://test-authkit.example.com"; |
| 15 | const audience = "client_test_fixture"; | 15 | const audience = "client_test_fixture"; |
| @@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { · 49 unmodified lines | |||
| 65 | }; | 65 | }; |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | interface StoreHarness extends JwksStore { | ||
| 69 | readonly seed: (stored: StoredJwks) => void; | ||
| 70 | readonly reads: () => number; | ||
| 71 | readonly writes: () => number; | ||
| 72 | } | ||
| 73 | |||
| 74 | /** Stands in for the Workers Cache API: shared across "isolates", in memory. */ | ||
| 75 | const makeStoreHarness = (): StoreHarness => { | ||
| 76 | const entries = new Map<string, StoredJwks>(); | ||
| 77 | let reads = 0; | ||
| 78 | let writes = 0; | ||
| 79 | return { | ||
| 80 | get: async (url) => { | ||
| 81 | reads++; | ||
| 82 | return entries.get(url.toString()) ?? null; | ||
| 83 | }, | ||
| 84 | put: async (url, stored) => { | ||
| 85 | writes++; | ||
| 86 | entries.set(url.toString(), stored); | ||
| 87 | }, | ||
| 88 | seed: (stored) => { | ||
| 89 | entries.set(jwksUrl.toString(), stored); | ||
| 90 | }, | ||
| 91 | reads: () => reads, | ||
| 92 | writes: () => writes, | ||
| 93 | }; | ||
| 94 | }; | ||
| 95 | |||
| 96 | /** A fetch that always fails, standing in for a slow/down key server. */ | ||
| 97 | const failingFetch: typeof globalThis.fetch = async () => { | ||
| 98 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting | ||
| 99 | throw new Error("JWKS endpoint unreachable"); | ||
| 100 | }; | ||
| 101 | |||
| 68 | describe("createCachedRemoteJWKSet", () => { | 102 | describe("createCachedRemoteJWKSet", () => { |
| 69 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { | 103 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { |
| 70 | const kp = await generateRotatableKeypair("k1"); | 104 | const kp = await generateRotatableKeypair("k1"); |
| @@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { · 97 unmodified lines | |||
| 168 | await jwtVerify(t1, jwks, { issuer, audience }); | 202 | await jwtVerify(t1, jwks, { issuer, audience }); |
| 169 | expect(harness.callCount()).toBe(2); | 203 | expect(harness.callCount()).toBe(2); |
| 170 | }); | 204 | }); |
| 205 | |||
| 206 | // ------------------------------------------------------------------------- | ||
| 207 | // Cross-isolate store — the cold-isolate path that caused the 2026-08-18 | ||
| 208 | // 500s: no module-scope entry, so every verify paid an upstream fetch. | ||
| 209 | // ------------------------------------------------------------------------- | ||
| 210 | |||
| 211 | it("a cold isolate serves from the store instead of going upstream", async () => { | ||
| 212 | const kp = await generateRotatableKeypair("k1"); | ||
| 213 | const store = makeStoreHarness(); | ||
| 214 | |||
| 215 | // First isolate: cold everywhere, so it fetches and populates the store. | ||
| 216 | const warm = makeFetchHarness([kp.publicJwk]); | ||
| 217 | const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); | ||
| 218 | const t1 = await sign(kp); | ||
| 219 | await jwtVerify(t1, first, { issuer, audience }); | ||
| 220 | expect(warm.callCount()).toBe(1); | ||
| 221 | expect(store.writes()).toBe(1); | ||
| 222 | |||
| 223 | // A brand-new isolate (fresh module scope). If it reaches upstream at all | ||
| 224 | // the fetch fails, so verifying proves the store answered. | ||
| 225 | const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); | ||
| 226 | const { payload } = await jwtVerify(t1, second, { issuer, audience }); | ||
| 227 | expect(payload.sub).toBe("user_test"); | ||
| 228 | expect(store.reads()).toBeGreaterThan(0); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("keeps serving the last good keys when the key server is down", async () => { | ||
| 232 | const kp = await generateRotatableKeypair("k1"); | ||
| 233 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 234 | let upstreamUp = true; | ||
| 235 | const flaky: typeof globalThis.fetch = (...args) => | ||
| 236 | upstreamUp ? harness.fetch(...args) : failingFetch(...args); | ||
| 237 | |||
| 238 | // ttl short enough that the next call is past it, stale window generous. | ||
| 239 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 240 | fetch: flaky, | ||
| 241 | store: null, | ||
| 242 | ttlMs: 10, | ||
| 243 | staleMaxMs: 60_000, | ||
| 244 | }); | ||
| 245 | |||
| 246 | const token = await sign(kp); | ||
| 247 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 248 | |||
| 249 | upstreamUp = false; | ||
| 250 | await new Promise((r) => setTimeout(r, 20)); | ||
| 251 | |||
| 252 | // Past the TTL with a dead upstream: the cached keys still verify. | ||
| 253 | const { payload } = await jwtVerify(token, jwks, { issuer, audience }); | ||
| 254 | expect(payload.sub).toBe("user_test"); | ||
| 255 | }); | ||
| 256 | |||
| 257 | it("stops serving stale keys once the stale window closes", async () => { | ||
| 258 | const kp = await generateRotatableKeypair("k1"); | ||
| 259 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 260 | let upstreamUp = true; | ||
| 261 | const flaky: typeof globalThis.fetch = (...args) => | ||
| 262 | upstreamUp ? harness.fetch(...args) : failingFetch(...args); | ||
| 263 | |||
| 264 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 265 | fetch: flaky, | ||
| 266 | store: null, | ||
| 267 | ttlMs: 5, | ||
| 268 | staleMaxMs: 10, | ||
| 269 | }); | ||
| 270 | |||
| 271 | const token = await sign(kp); | ||
| 272 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 273 | |||
| 274 | upstreamUp = false; | ||
| 275 | await new Promise((r) => setTimeout(r, 30)); | ||
| 276 | |||
| 277 | // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't | ||
| 278 | // silently honour a key set we can no longer confirm. | ||
| 279 | await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow(); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => { | ||
| 283 | const kp = await generateRotatableKeypair("k1"); | ||
| 284 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 285 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 286 | fetch: harness.fetch, | ||
| 287 | store: null, | ||
| 288 | ttlMs: 10, | ||
| 289 | staleMaxMs: 60_000, | ||
| 290 | }); | ||
| 291 | |||
| 292 | const token = await sign(kp); | ||
| 293 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 294 | expect(jwks.inspect().blockingFetchCount).toBe(1); | ||
| 295 | |||
| 296 | await new Promise((r) => setTimeout(r, 20)); | ||
| 297 | |||
| 298 | // Past the TTL: served from the stale entry, revalidated behind it. The | ||
| 299 | // caller waited on nothing, so latency must not be attributed to it. | ||
| 300 | const before = jwks.inspect(); | ||
| 301 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 302 | const after = jwks.inspect(); | ||
| 303 | |||
| 304 | expect(after.blockingFetchCount).toBe(before.blockingFetchCount); | ||
| 305 | expect(after.fetchCount).toBeGreaterThan(before.fetchCount); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it("records a cold-isolate store read as a store hit, not a fetch", async () => { | ||
| 309 | const kp = await generateRotatableKeypair("k1"); | ||
| 310 | const store = makeStoreHarness(); | ||
| 311 | const warm = makeFetchHarness([kp.publicJwk]); | ||
| 312 | |||
| 313 | const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); | ||
| 314 | const token = await sign(kp); | ||
| 315 | await jwtVerify(token, first, { issuer, audience }); | ||
| 316 | |||
| 317 | const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); | ||
| 318 | await jwtVerify(token, second, { issuer, audience }); | ||
| 319 | |||
| 320 | const stats = second.inspect(); | ||
| 321 | expect(stats.storeHitCount).toBe(1); | ||
| 322 | expect(stats.blockingFetchCount).toBe(0); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("does not block a request on revalidation once keys are cached", async () => { | ||
| 326 | const kp = await generateRotatableKeypair("k1"); | ||
| 327 | const harness = makeFetchHarness([kp.publicJwk]); | ||
| 328 | let hang = false; | ||
| 329 | const slowAfterFirst: typeof globalThis.fetch = async (...args) => { | ||
| 330 | if (hang) await new Promise((r) => setTimeout(r, 5_000)); | ||
| 331 | return harness.fetch(...args); | ||
| 332 | }; | ||
| 333 | |||
| 334 | const jwks = createCachedRemoteJWKSet(jwksUrl, { | ||
| 335 | fetch: slowAfterFirst, | ||
| 336 | store: null, | ||
| 337 | ttlMs: 10, | ||
| 338 | staleMaxMs: 60_000, | ||
| 339 | }); | ||
| 340 | |||
| 341 | const token = await sign(kp); | ||
| 342 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 343 | |||
| 344 | hang = true; | ||
| 345 | await new Promise((r) => setTimeout(r, 20)); | ||
| 346 | |||
| 347 | // The revalidation behind this call hangs for 5s; the verify must not. | ||
| 348 | const startedAt = Date.now(); | ||
| 349 | await jwtVerify(token, jwks, { issuer, audience }); | ||
| 350 | expect(Date.now() - startedAt).toBeLessThan(1_000); | ||
| 351 | }); | ||
| 171 | }); | 352 | }); |
| @@ -1,5 +1,5 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // In-memory JWKS cache for MCP JWT verification. | 2 | // JWKS cache for JWT verification (session + MCP auth). |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // | 4 | // |
| 5 | // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s | 5 | // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s |
| @@ -7,14 +7,32 @@ · 1 unmodified line | |||
| 7 | // fetches per hour because each new isolate starts cold. Production p99 for | 7 | // fetches per hour because each new isolate starts cold. Production p99 for |
| 8 | // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. | 8 | // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. |
| 9 | // | 9 | // |
| 10 | // This module offers a drop-in `createCachedRemoteJWKSet` that: | 10 | // Module-scope memory alone only helps while an isolate stays warm. When |
| 11 | // `workos.session.local_verify` started missing ~92% of the time (2026-08-18), | ||
| 12 | // every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s | ||
| 13 | // request timeout, turning a slow key-server into `AbortError` → 500 on every | ||
| 14 | // authenticated API route and 503 on `/mcp`. A transient key-server blip must | ||
| 15 | // never read as an auth failure, and a cold isolate must not have to go | ||
| 16 | // upstream at all. | ||
| 11 | // | 17 | // |
| 12 | // * Caches the JSON Web Key Set in module-scope memory for a configurable | 18 | // So this module layers three caches, fastest first: |
| 13 | // TTL (default 1 hour). | 19 | // |
| 14 | // * Single-flights concurrent fetches so a stampede of verifies during a | 20 | // 1. Module-scope memory — free, but dies with the isolate. |
| 15 | // cache miss only fires one upstream request. | 21 | // 2. A cross-isolate store (the Workers Cache API by default) — colo-local, |
| 16 | // * Force-refreshes once when verification fails with a cached key, so | 22 | // survives isolate recycling, so a cold isolate reads keys in ~1ms |
| 17 | // genuine key rotation isn't blocked by the TTL. | 23 | // instead of paying an upstream round trip. |
| 24 | // 3. The upstream JWKS endpoint. | ||
| 25 | // | ||
| 26 | // On top of that it is stale-while-revalidate: once past `ttlMs` a usable key | ||
| 27 | // set is served immediately and refreshed in the background, and if a refresh | ||
| 28 | // fails we keep serving the last good keys until `staleMaxMs`. Only a fully | ||
| 29 | // cold path (no memory, no store) ever blocks on the network. | ||
| 30 | // | ||
| 31 | // Serving stale keys is safe in a way that serving a stale *token* would not | ||
| 32 | // be: key sets rotate on the order of days, tokens are still signature- and | ||
| 33 | // expiry-checked against them, and a token whose `kid` is absent forces a real | ||
| 34 | // refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a | ||
| 35 | // retired key can still be honoured. | ||
| 18 | // | 36 | // |
| 19 | // The returned function is a `JWTVerifyGetKey` and slots directly into | 37 | // The returned function is a `JWTVerifyGetKey` and slots directly into |
| 20 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can | 38 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can |
| @@ -32,6 +50,21 @@ import { · 11 unmodified lines | |||
| 32 | import { Schema } from "effect"; | 50 | import { Schema } from "effect"; |
| 33 | import { JWKSNoMatchingKey } from "jose/errors"; | 51 | import { JWKSNoMatchingKey } from "jose/errors"; |
| 34 | 52 | ||
| 53 | /** | ||
| 54 | * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and | ||
| 55 | * non-Workers hosts pass their own, or `null` to stay memory-only. | ||
| 56 | */ | ||
| 57 | export interface JwksStore { | ||
| 58 | readonly get: (url: URL) => Promise<StoredJwks | null>; | ||
| 59 | readonly put: (url: URL, stored: StoredJwks) => Promise<void>; | ||
| 60 | } | ||
| 61 | |||
| 62 | export interface StoredJwks { | ||
| 63 | readonly jwks: JSONWebKeySet; | ||
| 64 | /** Epoch ms of the upstream fetch these keys came from. */ | ||
| 65 | readonly fetchedAt: number; | ||
| 66 | } | ||
| 67 | |||
| 35 | export interface CachedRemoteJWKSetOptions { | 68 | export interface CachedRemoteJWKSetOptions { |
| 36 | /** | 69 | /** |
| 37 | * How long a successful fetch is considered fresh. Defaults to 1 hour — | 70 | * How long a successful fetch is considered fresh. Defaults to 1 hour — |
| @@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { · 1 unmodified line | |||
| 39 | * failure handles unscheduled rotations. | 72 | * failure handles unscheduled rotations. |
| 40 | */ | 73 | */ |
| 41 | readonly ttlMs?: number; | 74 | readonly ttlMs?: number; |
| 75 | /** | ||
| 76 | * How long past `ttlMs` a key set may still be served while refreshes are | ||
| 77 | * failing. Defaults to 24h — long enough to ride out a key-server outage, | ||
| 78 | * short enough to bound how long a retired key stays honoured. | ||
| 79 | */ | ||
| 80 | readonly staleMaxMs?: number; | ||
| 42 | /** Override the fetch implementation for tests. */ | 81 | /** Override the fetch implementation for tests. */ |
| 43 | readonly fetch?: typeof globalThis.fetch; | 82 | readonly fetch?: typeof globalThis.fetch; |
| 44 | /** HTTP request timeout. Defaults to 5s, matching jose. */ | 83 | /** HTTP request timeout. Defaults to 5s, matching jose. */ |
| 45 | readonly timeoutMs?: number; | 84 | readonly timeoutMs?: number; |
| 85 | /** | ||
| 86 | * Cross-isolate store. Defaults to the Workers Cache API when available, | ||
| 87 | * `null` disables the layer (memory-only). | ||
| 88 | */ | ||
| 89 | readonly store?: JwksStore | null; | ||
| 46 | } | 90 | } |
| 47 | 91 | ||
| 48 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { | 92 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { |
| 49 | /** Drop the cached JWKS so the next call refetches. */ | 93 | /** Drop the cached JWKS so the next call refetches. */ |
| 50 | readonly forceRefresh: () => void; | 94 | readonly forceRefresh: () => void; |
| 51 | /** Inspect the current cache state (testing/diagnostics). */ | 95 | /** |
| 52 | readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean }; | 96 | * Inspect the current cache state (testing/diagnostics/span annotation). |
| 97 | * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver | ||
| 98 | * instance; callers snapshot them around a verify to tell a cache hit from | ||
| 99 | * a live upstream fetch (the Aug 2026 latency regression was exactly this | ||
| 100 | * cache silently missing on ~92% of verifies, invisible in traces). | ||
| 101 | * | ||
| 102 | * `blockingFetchCount` counts only fetches a verify actually WAITED on. | ||
| 103 | * Under stale-while-revalidate `fetchCount` also moves for background | ||
| 104 | * revalidation the caller never paid for, so latency attribution wants | ||
| 105 | * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered | ||
| 106 | * by the cross-isolate store instead of going upstream. | ||
| 107 | */ | ||
| 108 | readonly inspect: () => { | ||
| 109 | fetchedAt: number | null; | ||
| 110 | hasJwks: boolean; | ||
| 111 | fetchCount: number; | ||
| 112 | fetchFailureCount: number; | ||
| 113 | blockingFetchCount: number; | ||
| 114 | storeHitCount: number; | ||
| 115 | lastFetchDurationMs: number | null; | ||
| 116 | /** Wall-clock of the last cross-isolate store read (I/O). */ | ||
| 117 | lastStoreReadMs: number | null; | ||
| 118 | /** Wall-clock of the last key resolution — WebCrypto `importKey`. */ | ||
| 119 | lastResolveMs: number | null; | ||
| 120 | }; | ||
| 53 | } | 121 | } |
| 54 | 122 | ||
| 55 | const DEFAULT_TTL_MS = 60 * 60 * 1000; | 123 | const DEFAULT_TTL_MS = 60 * 60 * 1000; |
| 124 | const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000; | ||
| 56 | const DEFAULT_TIMEOUT_MS = 5000; | 125 | const DEFAULT_TIMEOUT_MS = 5000; |
| 57 | 126 | ||
| 58 | const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); | 127 | const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); |
| @@ -73,6 +142,26 @@ interface CacheEntry { · 14 unmodified lines | |||
| 73 | resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; | 142 | resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; |
| 74 | } | 143 | } |
| 75 | 144 | ||
| 145 | const entryFrom = (stored: StoredJwks): CacheEntry => ({ | ||
| 146 | jwks: stored.jwks, | ||
| 147 | fetchedAt: stored.fetchedAt, | ||
| 148 | resolver: createLocalJWKSet(stored.jwks), | ||
| 149 | }); | ||
| 150 | |||
| 151 | /** | ||
| 152 | * Cache upkeep (store writes, background revalidation) is best effort: it must | ||
| 153 | * never fail or delay the verify that happened to trigger it. | ||
| 154 | */ | ||
| 155 | const ignoreFailure = async (work: Promise<unknown>): Promise<void> => { | ||
| 156 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure | ||
| 157 | try { | ||
| 158 | await work; | ||
| 159 | } catch { | ||
| 160 | // Deliberately swallowed — the caller already has usable keys, or will | ||
| 161 | // take the upstream path on its next miss. | ||
| 162 | } | ||
| 163 | }; | ||
| 164 | |||
| 76 | const fetchJwksOnce = async ( | 165 | const fetchJwksOnce = async ( |
| 77 | url: URL, | 166 | url: URL, |
| 78 | fetchImpl: typeof globalThis.fetch, | 167 | fetchImpl: typeof globalThis.fetch, |
| @@ -107,17 +196,74 @@ const fetchJwksOnce = async ( · 28 unmodified lines | |||
| 107 | } | 196 | } |
| 108 | }; | 197 | }; |
| 109 | 198 | ||
| 199 | // --------------------------------------------------------------------------- | ||
| 200 | // Workers Cache API store | ||
| 201 | // --------------------------------------------------------------------------- | ||
| 202 | // | ||
| 203 | // Keyed by the JWKS URL itself. We store our own Response rather than the | ||
| 204 | // upstream one so the body is already-validated JSON and the cache headers are | ||
| 205 | // ours: `max-age` covers the stale window, because an entry past `ttlMs` is | ||
| 206 | // still useful to us as a stale fallback. | ||
| 207 | |||
| 208 | const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at"; | ||
| 209 | |||
| 210 | /** `caches.default` is a Workers extension the standard lib type omits. */ | ||
| 211 | type WorkersCacheStorage = CacheStorage & { readonly default?: Cache }; | ||
| 212 | |||
| 213 | const workersCacheStore = (staleMaxMs: number): JwksStore | null => { | ||
| 214 | if (typeof caches === "undefined") return null; | ||
| 215 | const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null; | ||
| 216 | |||
| 217 | return { | ||
| 218 | get: async (url) => { | ||
| 219 | const cache = open(); | ||
| 220 | if (!cache) return null; | ||
| 221 | const hit = await cache.match(url.toString()); | ||
| 222 | if (!hit) return null; | ||
| 223 | const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT); | ||
| 224 | const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader); | ||
| 225 | if (!Number.isFinite(fetchedAt)) return null; | ||
| 226 | const body = await hit.json(); | ||
| 227 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify | ||
| 228 | try { | ||
| 229 | await decodeJsonWebKeySetPayload(body); | ||
| 230 | } catch { | ||
| 231 | return null; | ||
| 232 | } | ||
| 233 | return { jwks: body as JSONWebKeySet, fetchedAt }; | ||
| 234 | }, | ||
| 235 | put: async (url, stored) => { | ||
| 236 | const cache = open(); | ||
| 237 | if (!cache) return; | ||
| 238 | const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000)); | ||
| 239 | await cache.put( | ||
| 240 | url.toString(), | ||
| 241 | new Response(JSON.stringify(stored.jwks), { | ||
| 242 | status: 200, | ||
| 243 | headers: { | ||
| 244 | "content-type": "application/json", | ||
| 245 | "cache-control": `max-age=${maxAgeSeconds}`, | ||
| 246 | [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt), | ||
| 247 | }, | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | }, | ||
| 251 | }; | ||
| 252 | }; | ||
| 253 | |||
| 110 | /** | 254 | /** |
| 111 | * Creates a cached, single-flight, force-refreshable JWKS resolver compatible | 255 | * Creates a cached, single-flight, force-refreshable JWKS resolver compatible |
| 112 | * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the | 256 | * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the |
| 113 | * MCP auth path — see module header for why we don't just use jose's built-in. | 257 | * auth paths — see module header for why we don't just use jose's built-in. |
| 114 | */ | 258 | */ |
| 115 | export const createCachedRemoteJWKSet = ( | 259 | export const createCachedRemoteJWKSet = ( |
| 116 | url: URL, | 260 | url: URL, |
| 117 | options: CachedRemoteJWKSetOptions = {}, | 261 | options: CachedRemoteJWKSetOptions = {}, |
| 118 | ): CachedRemoteJWKSet => { | 262 | ): CachedRemoteJWKSet => { |
| 119 | const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; | 263 | const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; |
| 264 | const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs); | ||
| 120 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | 265 | const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; |
| 266 | const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store; | ||
| 121 | // Capture the fetch impl lazily so consumers can swap globalThis.fetch | 267 | // Capture the fetch impl lazily so consumers can swap globalThis.fetch |
| 122 | // (tests do this) without us snapshotting a stale reference. | 268 | // (tests do this) without us snapshotting a stale reference. |
| 123 | const fetchImpl = (): typeof globalThis.fetch => | 269 | const fetchImpl = (): typeof globalThis.fetch => |
| @@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( · 1 unmodified line | |||
| 125 | 271 | ||
| 126 | let entry: CacheEntry | null = null; | 272 | let entry: CacheEntry | null = null; |
| 127 | let inflight: Promise<CacheEntry> | null = null; | 273 | let inflight: Promise<CacheEntry> | null = null; |
| 274 | let fetchCount = 0; | ||
| 275 | let fetchFailureCount = 0; | ||
| 276 | let blockingFetchCount = 0; | ||
| 277 | let storeHitCount = 0; | ||
| 278 | let lastFetchDurationMs: number | null = null; | ||
| 279 | let lastStoreReadMs: number | null = null; | ||
| 280 | let lastResolveMs: number | null = null; | ||
| 281 | |||
| 282 | const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs; | ||
| 283 | const isUsable = (candidate: CacheEntry): boolean => | ||
| 284 | Date.now() - candidate.fetchedAt < staleMaxMs; | ||
| 128 | 285 | ||
| 129 | const refresh = (): Promise<CacheEntry> => { | 286 | const refresh = (): Promise<CacheEntry> => { |
| 130 | if (inflight) return inflight; | 287 | if (inflight) return inflight; |
| 288 | const startedAt = Date.now(); | ||
| 289 | fetchCount += 1; | ||
| 131 | inflight = (async () => { | 290 | inflight = (async () => { |
| 132 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); | 291 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 133 | const next: CacheEntry = { | 292 | const next = entryFrom({ jwks, fetchedAt: Date.now() }); |
| 134 | jwks, | ||
| 135 | fetchedAt: Date.now(), | ||
| 136 | resolver: createLocalJWKSet(jwks), | ||
| 137 | }; | ||
| 138 | entry = next; | 293 | entry = next; |
| 294 | if (store) { | ||
| 295 | await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt })); | ||
| 296 | } | ||
| 139 | return next; | 297 | return next; |
| 140 | })().finally(() => { | 298 | })() |
| 141 | inflight = null; | 299 | .then( |
| 142 | }); | 300 | (next) => next, |
| 301 | (error: unknown) => { | ||
| 302 | fetchFailureCount += 1; | ||
| 303 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose | ||
| 304 | throw error; | ||
| 305 | }, | ||
| 306 | ) | ||
| 307 | .finally(() => { | ||
| 308 | lastFetchDurationMs = Date.now() - startedAt; | ||
| 309 | inflight = null; | ||
| 310 | }); | ||
| 143 | return inflight; | 311 | return inflight; |
| 144 | }; | 312 | }; |
| 145 | 313 | ||
| 146 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | 314 | /** Fire-and-forget revalidation behind a stale hit. */ |
| 147 | if (forceRefresh) return refresh(); | 315 | const refreshInBackground = (): void => { |
| 148 | if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; | 316 | void ignoreFailure(refresh()); |
| 317 | }; | ||
| 318 | |||
| 319 | const loadFromStore = async (): Promise<CacheEntry | null> => { | ||
| 320 | if (!store) return null; | ||
| 321 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch | ||
| 322 | try { | ||
| 323 | const storeReadStartedAt = Date.now(); | ||
| 324 | const stored = await store.get(url); | ||
| 325 | lastStoreReadMs = Date.now() - storeReadStartedAt; | ||
| 326 | if (!stored) return null; | ||
| 327 | const candidate = entryFrom(stored); | ||
| 328 | if (!isUsable(candidate)) return null; | ||
| 329 | entry = candidate; | ||
| 330 | storeHitCount += 1; | ||
| 331 | return candidate; | ||
| 332 | } catch { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | }; | ||
| 336 | |||
| 337 | /** A refresh the caller waits on — the only kind that costs it latency. */ | ||
| 338 | const refreshBlocking = (): Promise<CacheEntry> => { | ||
| 339 | blockingFetchCount += 1; | ||
| 149 | return refresh(); | 340 | return refresh(); |
| 150 | }; | 341 | }; |
| 151 | 342 | ||
| 343 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | ||
| 344 | if (forceRefresh) return refreshBlocking(); | ||
| 345 | if (entry && isFresh(entry)) return entry; | ||
| 346 | |||
| 347 | // Memory is stale but still usable: serve it now, revalidate behind it. | ||
| 348 | if (entry && isUsable(entry)) { | ||
| 349 | refreshInBackground(); | ||
| 350 | return entry; | ||
| 351 | } | ||
| 352 | |||
| 353 | // Cold isolate — the L2 store saves us the upstream round trip. | ||
| 354 | const stored = await loadFromStore(); | ||
| 355 | if (stored) { | ||
| 356 | if (!isFresh(stored)) refreshInBackground(); | ||
| 357 | return stored; | ||
| 358 | } | ||
| 359 | |||
| 360 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify | ||
| 361 | try { | ||
| 362 | return await refreshBlocking(); | ||
| 363 | } catch (error) { | ||
| 364 | // Upstream is slow or down. Last good keys beat failing every request. | ||
| 365 | if (entry && isUsable(entry)) return entry; | ||
| 366 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer | ||
| 367 | throw error; | ||
| 368 | } | ||
| 369 | }; | ||
| 370 | |||
| 152 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { | 371 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| 153 | const current = await ensureFresh(false); | 372 | const current = await ensureFresh(false); |
| 154 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures | 373 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures |
| 155 | try { | 374 | try { |
| 156 | return await current.resolver(protectedHeader, token); | 375 | const resolveStartedAt = Date.now(); |
| 376 | const key = await current.resolver(protectedHeader, token); | ||
| 377 | lastResolveMs = Date.now() - resolveStartedAt; | ||
| 378 | return key; | ||
| 157 | } catch (error) { | 379 | } catch (error) { |
| 158 | // Likely cause: keys rotated upstream after our TTL window started. | 380 | // Likely cause: keys rotated upstream after our TTL window started, or |
| 159 | // Refetch once and try again. Anything still failing bubbles up so | 381 | // we answered from a stale entry. Refetch once and try again. Anything |
| 160 | // jose can classify it (we do not silently swallow real failures). | 382 | // still failing bubbles up so jose can classify it (we do not silently |
| 383 | // swallow real failures). | ||
| 161 | if (!isJwksNoMatchingKey(error)) { | 384 | if (!isJwksNoMatchingKey(error)) { |
| 162 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection | 385 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection |
| 163 | throw error; | 386 | throw error; |
| @@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( · 13 unmodified lines | |||
| 177 | value: () => ({ | 400 | value: () => ({ |
| 178 | fetchedAt: entry?.fetchedAt ?? null, | 401 | fetchedAt: entry?.fetchedAt ?? null, |
| 179 | hasJwks: entry !== null, | 402 | hasJwks: entry !== null, |
| 403 | fetchCount, | ||
| 404 | fetchFailureCount, | ||
| 405 | blockingFetchCount, | ||
| 406 | storeHitCount, | ||
| 407 | lastFetchDurationMs, | ||
| 408 | lastStoreReadMs, | ||
| 409 | lastResolveMs, | ||
| 180 | }), | 410 | }), |
| 181 | }); | 411 | }); |
| 182 | return result; | 412 | return result; |
| @@ -0,0 +1,32 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Last-visited-org cookie — the browser's "which org was I working in" memory. | ||
| 3 | // | ||
| 4 | // Org switching is stateless by design (the URL slug scopes every request; see | ||
| 5 | // organization.ts), which leaves nothing that remembers the last org across | ||
| 6 | // entries: the sealed session still carries the org pinned at LOGIN time, so a | ||
| 7 | // bare URL (`executor.sh/`) would always canonicalize onto that first org. This | ||
| 8 | // cookie fills the gap: the client records the slug of the org it's verifiably | ||
| 9 | // viewing, and the two bare-entry deciders honor it — | ||
| 10 | // | ||
| 11 | // - the document auth gate redirects bare document paths onto it (doc-gate.ts) | ||
| 12 | // - the login callback prefers it when picking the org for a fresh session | ||
| 13 | // with a bare returnTo (handlers.ts) | ||
| 14 | // | ||
| 15 | // It is a PREFERENCE, never an authority: both readers re-check live membership | ||
| 16 | // through the same authorize path as any org selector, so a stale or forged | ||
| 17 | // value at worst falls back to today's behavior. Not HttpOnly — the client is | ||
| 18 | // the writer. Deliberately NOT cleared on logout: surviving the session is what | ||
| 19 | // lets the next login land on the last-used org. | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | export const LAST_ORG_COOKIE = "executor-last-org"; | ||
| 23 | |||
| 24 | /** Outlives the 7d session on purpose — it spans logins. */ | ||
| 25 | const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; | ||
| 26 | |||
| 27 | /** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */ | ||
| 28 | export const writeLastOrgCookie = (slug: string): void => { | ||
| 29 | document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${ | ||
| 30 | window.location.protocol === "https:" ? "; Secure" : "" | ||
| 31 | }`; | ||
| 32 | }; | ||
| @@ -0,0 +1,159 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyService } from "./api-keys"; | ||
| 5 | import { UserStoreService } from "./context"; | ||
| 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | ||
| 7 | import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; | ||
| 8 | |||
| 9 | // Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform | ||
| 10 | // view (tenant-wide, read-only, NO acting member) rather than to a member | ||
| 11 | // `Principal`. The product surfaces must reject it outright rather than | ||
| 12 | // inventing a subject for it — that is the property these tests pin. | ||
| 13 | |||
| 14 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | ||
| 15 | |||
| 16 | const stubApiKeys = Layer.succeed(ApiKeyService)({ | ||
| 17 | validate: (value: string) => { | ||
| 18 | if (value === "valid_org_key") { | ||
| 19 | return Effect.succeed({ | ||
| 20 | scope: "org" as const, | ||
| 21 | accountId: null, | ||
| 22 | organizationId: "org_123", | ||
| 23 | keyId: "api_key_org", | ||
| 24 | }); | ||
| 25 | } | ||
| 26 | if (value === "valid_user_key") { | ||
| 27 | return Effect.succeed({ | ||
| 28 | scope: "user" as const, | ||
| 29 | accountId: "user_123", | ||
| 30 | organizationId: "org_123", | ||
| 31 | keyId: "api_key_123", | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | return Effect.succeed(null); | ||
| 35 | }, | ||
| 36 | listUserKeys: () => Effect.succeed([]), | ||
| 37 | createUserKey: () => Effect.die("org api key test does not create API keys"), | ||
| 38 | revokeUserKey: () => Effect.void, | ||
| 39 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 40 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 41 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 42 | }); | ||
| 43 | |||
| 44 | const stubWorkOS = Layer.succeed( | ||
| 45 | WorkOSClient, | ||
| 46 | new Proxy({} as WorkOSClientService, { | ||
| 47 | get: (_target, prop) => { | ||
| 48 | if (prop === "listUserMemberships") { | ||
| 49 | return (userId: string) => | ||
| 50 | Effect.succeed({ | ||
| 51 | data: | ||
| 52 | userId === "user_123" | ||
| 53 | ? [{ userId, organizationId: "org_123", status: "active" }] | ||
| 54 | : [], | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | // An org key must NOT trigger a membership check — there is no user to | ||
| 58 | // check. Any such call dies here, which is the assertion. | ||
| 59 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 60 | }, | ||
| 61 | }), | ||
| 62 | ); | ||
| 63 | |||
| 64 | const stubUsers = Layer.succeed(UserStoreService)({ | ||
| 65 | use: (_op, fn) => | ||
| 66 | Effect.promise(() => | ||
| 67 | fn({ | ||
| 68 | ensureAccount: async (id: string) => ({ id, createdAt }), | ||
| 69 | getAccount: async (id: string) => ({ id, createdAt }), | ||
| 70 | upsertOrganization: async (org: { id: string; name: string }) => ({ | ||
| 71 | ...org, | ||
| 72 | slug: `org-slug-${org.id}`, | ||
| 73 | createdAt, | ||
| 74 | }), | ||
| 75 | getOrganization: async (id: string) => ({ | ||
| 76 | id, | ||
| 77 | name: `Org ${id}`, | ||
| 78 | slug: `org-slug-${id}`, | ||
| 79 | createdAt, | ||
| 80 | }), | ||
| 81 | getOrganizationBySlug: async (slug: string) => ({ | ||
| 82 | id: "org_by_slug", | ||
| 83 | name: `Org ${slug}`, | ||
| 84 | slug, | ||
| 85 | createdAt, | ||
| 86 | }), | ||
| 87 | deleteOrganizationCascade: async () => {}, | ||
| 88 | }), | ||
| 89 | ), | ||
| 90 | }); | ||
| 91 | |||
| 92 | const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers); | ||
| 93 | |||
| 94 | const bearer = (token: string) => | ||
| 95 | new Request("https://executor.test/api/tools", { | ||
| 96 | headers: { authorization: `Bearer ${token}` }, | ||
| 97 | }); | ||
| 98 | |||
| 99 | describe("org-level API keys", () => { | ||
| 100 | it.effect("resolve to the platform view with no acting member", () => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | ||
| 103 | |||
| 104 | expect(isPlatformAuth(auth)).toBe(true); | ||
| 105 | expect(auth).toEqual({ | ||
| 106 | kind: "platform", | ||
| 107 | organizationId: "org_123", | ||
| 108 | organizationName: "Org org_123", | ||
| 109 | organizationSlug: "org-slug-org_123", | ||
| 110 | keyId: "api_key_org", | ||
| 111 | }); | ||
| 112 | // No `accountId` anywhere in the shape: nothing downstream can bind a | ||
| 113 | // subject from it by accident. | ||
| 114 | expect(auth).not.toHaveProperty("accountId"); | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | |||
| 118 | it.effect("user keys still resolve to a bound member principal", () => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers)); | ||
| 121 | |||
| 122 | expect(isPlatformAuth(auth)).toBe(false); | ||
| 123 | expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" }); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("resolve on the product surface as a platform principal, never a subject", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | // THE security property of the api-key half, updated for platform reads: | ||
| 130 | // a product request carrying an org key resolves to the NEUTRAL platform | ||
| 131 | // shape — which the shared middleware routes to the subject-less, | ||
| 132 | // GET-only platform executor — and must never carry an accountId a | ||
| 133 | // handler could bind as an acting member. | ||
| 134 | const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe( | ||
| 135 | Effect.provide(layers), | ||
| 136 | ); | ||
| 137 | |||
| 138 | expect(principal).toEqual({ | ||
| 139 | kind: "platform", | ||
| 140 | organizationId: "org_123", | ||
| 141 | organizationName: "Org org_123", | ||
| 142 | organizationSlug: "org-slug-org_123", | ||
| 143 | keyId: "api_key_org", | ||
| 144 | }); | ||
| 145 | expect(principal).not.toHaveProperty("accountId"); | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("do not trigger a user membership check", () => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | // `authorizeOrganization` checks a USER's live membership; there is no | ||
| 152 | // user here. The WorkOS stub dies on any call other than the user path, | ||
| 153 | // so a clean resolution proves the org branch never took it. | ||
| 154 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); | ||
| 155 | |||
| 156 | expect(isPlatformAuth(auth)).toBe(true); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | }); | ||
| @@ -0,0 +1,316 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; | ||
| 5 | import { ApiKeyManagementError } from "./errors"; | ||
| 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | ||
| 7 | |||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | // Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged | ||
| 10 | // credential that resolves to the read-only platform view (`/admin/*`). | ||
| 11 | // | ||
| 12 | // The decode boundary is what these pin. WorkOS returns an org-owned key with | ||
| 13 | // the ORG in `owner.id` and no member at all, which is a different shape from | ||
| 14 | // the user-owned keys the console's personal-key CRUD handles. The workspace | ||
| 15 | // api key is also workspace-WIDE, so a response naming a different org than the | ||
| 16 | // one asked for must not be surfaced as that org's key. | ||
| 17 | // | ||
| 18 | // NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes | ||
| 19 | // `owner.type: "user"` and exposes no org-key mint route, so this path cannot | ||
| 20 | // be exercised over the wire in e2e today. These stubs encode the REAL WorkOS | ||
| 21 | // API shape (`organizations.createOrganizationApiKey` / | ||
| 22 | // `listOrganizationApiKeys`, per the installed SDK's types). | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | const ORG = "org_123"; | ||
| 26 | |||
| 27 | const orgKeyResponse = (organizationId: string, id: string, name: string) => ({ | ||
| 28 | object: "api_key", | ||
| 29 | id, | ||
| 30 | owner: { type: "organization", id: organizationId }, | ||
| 31 | name, | ||
| 32 | obfuscatedValue: "sk_org…a1b2", | ||
| 33 | lastUsedAt: null, | ||
| 34 | permissions: [], | ||
| 35 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 37 | }); | ||
| 38 | |||
| 39 | const stubWorkOS = (overrides: Record<string, unknown> = {}) => | ||
| 40 | Layer.succeed( | ||
| 41 | WorkOSClient, | ||
| 42 | new Proxy({} as WorkOSClientService, { | ||
| 43 | get: (_target, prop) => { | ||
| 44 | const override = overrides[String(prop)]; | ||
| 45 | if (override) return override; | ||
| 46 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 47 | }, | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const serviceWith = (overrides: Record<string, unknown>) => | ||
| 52 | ApiKeyService.asEffect().pipe( | ||
| 53 | Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))), | ||
| 54 | ); | ||
| 55 | |||
| 56 | describe("organization API keys", () => { | ||
| 57 | it.effect("mints an org-owned key and returns the one-time secret", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const apiKeys = yield* serviceWith({ | ||
| 60 | createOrgApiKey: (params: { organizationId: string; name: string }) => | ||
| 61 | Effect.succeed({ | ||
| 62 | ...orgKeyResponse(params.organizationId, "key_org_1", params.name), | ||
| 63 | value: "sk_org_plaintext", | ||
| 64 | }), | ||
| 65 | }); | ||
| 66 | |||
| 67 | const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }); | ||
| 68 | |||
| 69 | expect(created.id).toBe("key_org_1"); | ||
| 70 | expect(created.name).toBe("acme-backend"); | ||
| 71 | expect(created.value, "create returns the one-time plaintext secret").toBe( | ||
| 72 | "sk_org_plaintext", | ||
| 73 | ); | ||
| 74 | expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe( | ||
| 75 | created.value, | ||
| 76 | ); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | it.effect("lists the organization's own keys, masked", () => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const apiKeys = yield* serviceWith({ | ||
| 83 | listOrgApiKeys: (organizationId: string) => | ||
| 84 | Effect.succeed({ | ||
| 85 | object: "list", | ||
| 86 | data: [ | ||
| 87 | orgKeyResponse(organizationId, "key_org_1", "acme-backend"), | ||
| 88 | orgKeyResponse(organizationId, "key_org_2", "ci"), | ||
| 89 | ], | ||
| 90 | listMetadata: { before: null, after: null }, | ||
| 91 | }), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 95 | |||
| 96 | expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]); | ||
| 97 | expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain( | ||
| 98 | "sk_org_plaintext", | ||
| 99 | ); | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | |||
| 103 | it.effect("drops a key owned by a DIFFERENT organization", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | // The WorkOS workspace api key is workspace-wide, so a response naming | ||
| 106 | // another org must never be surfaced as this org's key. | ||
| 107 | const apiKeys = yield* serviceWith({ | ||
| 108 | listOrgApiKeys: () => | ||
| 109 | Effect.succeed({ | ||
| 110 | object: "list", | ||
| 111 | data: [ | ||
| 112 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 113 | orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 114 | ], | ||
| 115 | listMetadata: { before: null, after: null }, | ||
| 116 | }), | ||
| 117 | }); | ||
| 118 | |||
| 119 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 120 | |||
| 121 | expect( | ||
| 122 | keys.map((key) => key.id), | ||
| 123 | "only this org's key survives", | ||
| 124 | ).toEqual(["key_mine"]); | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | |||
| 128 | it.effect("refuses a create response that names another organization", () => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const apiKeys = yield* serviceWith({ | ||
| 131 | createOrgApiKey: () => | ||
| 132 | Effect.succeed({ | ||
| 133 | ...orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 134 | value: "sk_org_plaintext", | ||
| 135 | }), | ||
| 136 | }); | ||
| 137 | |||
| 138 | const error = yield* Effect.flip( | ||
| 139 | apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(error).toBeInstanceOf(ApiKeyManagementError); | ||
| 143 | }), | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.effect("ignores a user-owned key in an org-key listing", () => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | // The org and user shapes are decoded separately on purpose; a user key | ||
| 149 | // appearing here would mean the caller could mistake a member's personal | ||
| 150 | // credential for a workspace one. | ||
| 151 | const apiKeys = yield* serviceWith({ | ||
| 152 | listOrgApiKeys: () => | ||
| 153 | Effect.succeed({ | ||
| 154 | object: "list", | ||
| 155 | data: [ | ||
| 156 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 157 | { | ||
| 158 | object: "api_key", | ||
| 159 | id: "key_user", | ||
| 160 | owner: { type: "user", id: "user_1", organization_id: ORG }, | ||
| 161 | name: "personal", | ||
| 162 | obfuscatedValue: "sk_user…c3d4", | ||
| 163 | lastUsedAt: null, | ||
| 164 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 165 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 166 | }, | ||
| 167 | ], | ||
| 168 | listMetadata: { before: null, after: null }, | ||
| 169 | }), | ||
| 170 | }); | ||
| 171 | |||
| 172 | const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); | ||
| 173 | |||
| 174 | expect( | ||
| 175 | keys.map((key) => key.id), | ||
| 176 | "a user-owned key is not an org key", | ||
| 177 | ).toEqual(["key_mine"]); | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | }); | ||
| 181 | |||
| 182 | // --------------------------------------------------------------------------- | ||
| 183 | // Revoking an org key. The mint is only half a credential lifecycle: an org key | ||
| 184 | // reads every user and connection in the tenant, so it has to be destroyable. | ||
| 185 | // | ||
| 186 | // Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves | ||
| 187 | // ownership against the CALLER's personal keys, so an org key id is never in | ||
| 188 | // its owned set. It also cannot just call `deleteApiKey` directly: that route is | ||
| 189 | // scope-blind and would happily destroy a member's personal key, or a key | ||
| 190 | // belonging to a sibling org in the same WorkOS workspace. So ownership is | ||
| 191 | // resolved against the ORG's decoded listing first, and that filter IS the | ||
| 192 | // authorization. These pin that filter. | ||
| 193 | // --------------------------------------------------------------------------- | ||
| 194 | |||
| 195 | describe("revoking an organization API key", () => { | ||
| 196 | const listing = (...keys: ReadonlyArray<unknown>) => ({ | ||
| 197 | object: "list", | ||
| 198 | data: keys, | ||
| 199 | listMetadata: { before: null, after: null }, | ||
| 200 | }); | ||
| 201 | |||
| 202 | it.effect("revokes a key the organization owns", () => | ||
| 203 | Effect.gen(function* () { | ||
| 204 | const deleted: string[] = []; | ||
| 205 | const apiKeys = yield* serviceWith({ | ||
| 206 | listOrgApiKeys: (organizationId: string) => | ||
| 207 | Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | ||
| 208 | deleteApiKey: (id: string) => | ||
| 209 | Effect.sync(() => { | ||
| 210 | deleted.push(id); | ||
| 211 | }), | ||
| 212 | }); | ||
| 213 | |||
| 214 | yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }); | ||
| 215 | |||
| 216 | expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([ | ||
| 217 | "key_org_1", | ||
| 218 | ]); | ||
| 219 | }), | ||
| 220 | ); | ||
| 221 | |||
| 222 | it.effect("refuses a USER-owned key id, without deleting anything", () => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | // The regression this guards: a member's personal credential must not be | ||
| 225 | // destroyable through the org route just because an admin can call it. | ||
| 226 | const deleted: string[] = []; | ||
| 227 | const apiKeys = yield* serviceWith({ | ||
| 228 | listOrgApiKeys: () => | ||
| 229 | Effect.succeed( | ||
| 230 | listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), { | ||
| 231 | object: "api_key", | ||
| 232 | id: "key_user", | ||
| 233 | owner: { type: "user", id: "user_1", organization_id: ORG }, | ||
| 234 | name: "personal", | ||
| 235 | obfuscatedValue: "sk_user…c3d4", | ||
| 236 | lastUsedAt: null, | ||
| 237 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 238 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | deleteApiKey: (id: string) => | ||
| 242 | Effect.sync(() => { | ||
| 243 | deleted.push(id); | ||
| 244 | }), | ||
| 245 | }); | ||
| 246 | |||
| 247 | const error = yield* Effect.flip( | ||
| 248 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }), | ||
| 249 | ); | ||
| 250 | |||
| 251 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 252 | expect(deleted, "no delete is issued for a key the org does not own").toEqual([]); | ||
| 253 | }), | ||
| 254 | ); | ||
| 255 | |||
| 256 | it.effect("refuses a key belonging to ANOTHER organization", () => | ||
| 257 | Effect.gen(function* () { | ||
| 258 | // The WorkOS workspace key is workspace-wide, so an admin of one org must | ||
| 259 | // not be able to revoke a sibling org's credential by id. | ||
| 260 | const deleted: string[] = []; | ||
| 261 | const apiKeys = yield* serviceWith({ | ||
| 262 | listOrgApiKeys: () => | ||
| 263 | Effect.succeed( | ||
| 264 | listing( | ||
| 265 | orgKeyResponse(ORG, "key_mine", "mine"), | ||
| 266 | orgKeyResponse("org_other", "key_theirs", "theirs"), | ||
| 267 | ), | ||
| 268 | ), | ||
| 269 | deleteApiKey: (id: string) => | ||
| 270 | Effect.sync(() => { | ||
| 271 | deleted.push(id); | ||
| 272 | }), | ||
| 273 | }); | ||
| 274 | |||
| 275 | const error = yield* Effect.flip( | ||
| 276 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 280 | expect(deleted).toEqual([]); | ||
| 281 | }), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect("refuses an unknown key id", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const apiKeys = yield* serviceWith({ | ||
| 287 | listOrgApiKeys: () => Effect.succeed(listing()), | ||
| 288 | deleteApiKey: () => Effect.die("nothing should be deleted"), | ||
| 289 | }); | ||
| 290 | |||
| 291 | const error = yield* Effect.flip( | ||
| 292 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }), | ||
| 293 | ); | ||
| 294 | |||
| 295 | expect(error).toBeInstanceOf(OrgApiKeyNotFound); | ||
| 296 | }), | ||
| 297 | ); | ||
| 298 | |||
| 299 | it.effect("surfaces an upstream delete failure as a management error", () => | ||
| 300 | Effect.gen(function* () { | ||
| 301 | // A failed delete must not read as a successful revoke — the console | ||
| 302 | // would tell an admin a live credential is gone. | ||
| 303 | const apiKeys = yield* serviceWith({ | ||
| 304 | listOrgApiKeys: (organizationId: string) => | ||
| 305 | Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), | ||
| 306 | deleteApiKey: () => Effect.fail("workos_unavailable"), | ||
| 307 | }); | ||
| 308 | |||
| 309 | const error = yield* Effect.flip( | ||
| 310 | apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }), | ||
| 311 | ); | ||
| 312 | |||
| 313 | expect(error).toBeInstanceOf(ApiKeyManagementError); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | }); | ||
| @@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines | |||
| 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 8 | 8 | ||
| 9 | // The org a console request resolves to is the URL's org (sent in the | 9 | // The org a console request resolves to is the URL's org (sent in the |
| 10 | // `x-executor-organization` selector header), not the session's stored org — | 10 | // `x-executor-organization` selector header) — NEVER the session's stored org. |
| 11 | // with the session org as the fallback for non-console callers, and live | 11 | // The sealed cookie's org is a browser-global pinned to whichever org WorkOS |
| 12 | // membership re-checked either way. This is what makes two browser tabs on | 12 | // last touched, so a fallback to it silently scopes a multi-org user's request |
| 13 | // to the wrong org; a header-less request fails closed instead. Live | ||
| 14 | // membership is re-checked either way. This is what makes two browser tabs on | ||
| 13 | // different orgs independent. | 15 | // different orgs independent. |
| 14 | 16 | ||
| 15 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | 17 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); |
| @@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 11 unmodified lines | |||
| 27 | listUserKeys: () => Effect.succeed([]), | 29 | listUserKeys: () => Effect.succeed([]), |
| 28 | createUserKey: () => Effect.die("not used"), | 30 | createUserKey: () => Effect.die("not used"), |
| 29 | revokeUserKey: () => Effect.void, | 31 | revokeUserKey: () => Effect.void, |
| 32 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), | ||
| 33 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), | ||
| 34 | revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | ||
| 30 | }); | 35 | }); |
| 31 | 36 | ||
| 32 | const stubWorkOS = Layer.succeed( | 37 | const stubWorkOS = Layer.succeed( |
| @@ -35,7 +40,11 @@ const stubWorkOS = Layer.succeed( · 2 unmodified lines | |||
| 35 | get: (_t, prop) => { | 40 | get: (_t, prop) => { |
| 36 | if (prop === "authenticateRequest") { | 41 | if (prop === "authenticateRequest") { |
| 37 | return () => | 42 | return () => |
| 38 | Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG }); | 43 | Effect.succeed({ |
| 44 | userId: MEMBER, | ||
| 45 | email: "u@e2e.test", | ||
| 46 | organizationId: SESSION_ORG, | ||
| 47 | }); | ||
| 39 | } | 48 | } |
| 40 | if (prop === "listUserMemberships") { | 49 | if (prop === "listUserMemberships") { |
| 41 | return (userId: string) => | 50 | return (userId: string) => |
| @@ -55,7 +64,7 @@ const stubWorkOS = Layer.succeed( · 13 unmodified lines | |||
| 55 | ); | 64 | ); |
| 56 | 65 | ||
| 57 | const stubUsers = Layer.succeed(UserStoreService)({ | 66 | const stubUsers = Layer.succeed(UserStoreService)({ |
| 58 | use: (fn) => | 67 | use: (_op, fn) => |
| 59 | Effect.promise(() => | 68 | Effect.promise(() => |
| 60 | fn({ | 69 | fn({ |
| 61 | ensureAccount: async (id: string) => ({ id, createdAt }), | 70 | ensureAccount: async (id: string) => ({ id, createdAt }), |
| @@ -66,7 +75,12 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 4 unmodified lines | |||
| 66 | slug: org.id, | 75 | slug: org.id, |
| 67 | createdAt, | 76 | createdAt, |
| 68 | }), | 77 | }), |
| 69 | getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), | 78 | getOrganization: async (id: string) => ({ |
| 79 | id, | ||
| 80 | name: `Org ${id}`, | ||
| 81 | slug: id, | ||
| 82 | createdAt, | ||
| 83 | }), | ||
| 70 | // The URL slug maps to URL_ORG (the member's other org); any other slug | 84 | // The URL slug maps to URL_ORG (the member's other org); any other slug |
| 71 | // maps to an org the caller is NOT a member of, so membership rejects it. | 85 | // maps to an org the caller is NOT a member of, so membership rejects it. |
| 72 | getOrganizationBySlug: async (slug: string) => ({ | 86 | getOrganizationBySlug: async (slug: string) => ({ |
| @@ -75,6 +89,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 2 unmodified lines | |||
| 75 | slug, | 89 | slug, |
| 76 | createdAt, | 90 | createdAt, |
| 77 | }), | 91 | }), |
| 92 | deleteOrganizationCascade: async () => {}, | ||
| 78 | }), | 93 | }), |
| 79 | ), | 94 | ), |
| 80 | }); | 95 | }); |
| @@ -85,10 +100,15 @@ const run = (headers: Record<string, string>) => · 4 unmodified lines | |||
| 85 | ); | 100 | ); |
| 86 | 101 | ||
| 87 | describe("resolveSessionPrincipal · URL org selector", () => { | 102 | describe("resolveSessionPrincipal · URL org selector", () => { |
| 88 | 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", () => |
| 89 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 90 | const principal = yield* run({ cookie: "wos-session=x" }); | 105 | // No fallback to the session org: the cookie's org is browser-global |
| 91 | expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG); | 106 | // and can name a different org than the tab's URL for a multi-org user. |
| 107 | const error = yield* Effect.flip(run({ cookie: "wos-session=x" })); | ||
| 108 | expect(error, "rejects instead of scoping to the session org").toMatchObject({ | ||
| 109 | _tag: "NoOrganization", | ||
| 110 | code: "no_organization", | ||
| 111 | }); | ||
| 92 | }), | 112 | }), |
| 93 | ); | 113 | ); |
| 94 | 114 | ||
| @@ -117,7 +137,10 @@ describe("resolveSessionPrincipal · URL org selector", () => { · 22 unmodified lines | |||
| 117 | // The slug resolves to a real org id, but membership is re-checked — a | 137 | // The slug resolves to a real org id, but membership is re-checked — a |
| 118 | // slug is a selector, not a trust boundary, so a non-member is rejected. | 138 | // slug is a selector, not a trust boundary, so a non-member is rejected. |
| 119 | const error = yield* Effect.flip( | 139 | const error = yield* Effect.flip( |
| 120 | run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }), | 140 | run({ |
| 141 | cookie: "wos-session=x", | ||
| 142 | "x-executor-organization": "outsider-slug", | ||
| 143 | }), | ||
| 121 | ); | 144 | ); |
| 122 | expect(error).toMatchObject({ _tag: "NoOrganization" }); | 145 | expect(error).toMatchObject({ _tag: "NoOrganization" }); |
| 123 | }), | 146 | }), |
| @@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; · 37 unmodified lines | |||
| 38 | export const resolveOrganization = (organizationId: string) => | 38 | export const resolveOrganization = (organizationId: string) => |
| 39 | Effect.gen(function* () { | 39 | Effect.gen(function* () { |
| 40 | const users = yield* UserStoreService; | 40 | const users = yield* UserStoreService; |
| 41 | const existing = yield* users.use((s) => s.getOrganization(organizationId)); | 41 | const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); |
| 42 | if (existing) return existing; | 42 | if (existing) return existing; |
| 43 | 43 | ||
| 44 | const workos = yield* WorkOSClient; | 44 | const workos = yield* WorkOSClient; |
| 45 | const fresh = yield* workos.getOrganization(organizationId); | 45 | const fresh = yield* workos.getOrganization(organizationId); |
| 46 | return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); | 46 | return yield* users.use("upsertOrganization", (s) => |
| 47 | s.upsertOrganization({ id: fresh.id, name: fresh.name }), | ||
| 48 | ); | ||
| 47 | }); | 49 | }); |
| 48 | 50 | ||
| 49 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| @@ -57,9 +59,19 @@ export const resolveOrganization = (organizationId: string) => · 7 unmodified lines | |||
| 57 | // until their access token naturally expires (~10 min). | 59 | // until their access token naturally expires (~10 min). |
| 58 | // | 60 | // |
| 59 | // To close that gap we verify membership live on every protected request. | 61 | // To close that gap we verify membership live on every protected request. |
| 60 | // `listUserMemberships` is one WorkOS call per request. If this becomes a | 62 | // `listUserMemberships` is one WorkOS call per request. |
| 61 | // hot path we can layer a short per-(user, org) TTL cache underneath, or | 63 | // |
| 62 | // swap it for a local memberships table fed by the WorkOS Events API. | 64 | // Caching decision (2026-07): we deliberately do NOT add a positive TTL cache |
| 65 | // here. A positive cache is exactly what would re-open the revocation gap this | ||
| 66 | // live check exists to close — a revoked member would keep access for the cache | ||
| 67 | // TTL. Negative caching is worse still (a transient WorkOS blip would get | ||
| 68 | // pinned as "no access"), so it is out too. The rate-limit amplification a | ||
| 69 | // shared-API-key org can cause under a WorkOS slowdown is mitigated instead by | ||
| 70 | // the classification fix at the MCP call site (a blip now yields a retryable | ||
| 71 | // 503, so it no longer condemns sessions or triggers reconnect storms). If per- | ||
| 72 | // request WorkOS load later proves to be the bottleneck, the right structural | ||
| 73 | // fix is a local memberships table fed by the WorkOS Events API (authoritative, | ||
| 74 | // no staleness window), not a TTL cache over this call — tracked as follow-up. | ||
| 63 | // | 75 | // |
| 64 | // Returns the resolved organization (via resolveOrganization) if the user | 76 | // Returns the resolved organization (via resolveOrganization) if the user |
| 65 | // currently holds an *active* membership in it, otherwise null. Callers | 77 | // currently holds an *active* membership in it, otherwise null. Callers |
| @@ -115,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) · 49 unmodified lines | |||
| 115 | return yield* authorizeOrganization(userId, selector); | 127 | return yield* authorizeOrganization(userId, selector); |
| 116 | } | 128 | } |
| 117 | const users = yield* UserStoreService; | 129 | const users = yield* UserStoreService; |
| 118 | const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); | 130 | const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); |
| 119 | if (!org) return null; | 131 | if (!org) return null; |
| 120 | return yield* authorizeOrganization(userId, org.id); | 132 | return yield* authorizeOrganization(userId, org.id); |
| 121 | }); | 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 | }); | ||
| @@ -13,6 +13,7 @@ import { generateOrgSlug } from "@executor-js/api"; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | import { accounts, organizations } from "../db/schema"; | 14 | import { accounts, organizations } from "../db/schema"; |
| 15 | import type { DrizzleDb } from "../db/db"; | 15 | import type { DrizzleDb } from "../db/db"; |
| 16 | import { purgeOrganizationData } from "../db/org-deletion"; | ||
| 16 | 17 | ||
| 17 | export type Account = typeof accounts.$inferSelect; | 18 | export type Account = typeof accounts.$inferSelect; |
| 18 | export type Organization = typeof organizations.$inferSelect; | 19 | export type Organization = typeof organizations.$inferSelect; |
| @@ -96,5 +97,10 @@ export const makeUserStore = (db: DrizzleDb) => { · 77 unmodified lines | |||
| 96 | const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); | 97 | const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); |
| 97 | return rows[0] ?? null; | 98 | return rows[0] ?? null; |
| 98 | }, | 99 | }, |
| 100 | |||
| 101 | // Permanently delete an org and everything it owns (tenant data, secrets, | ||
| 102 | // identity mirror + cascaded memberships) in a single transaction. Callers | ||
| 103 | // sequence the external WorkOS/Autumn deletions around this. | ||
| 104 | deleteOrganizationCascade: (id: string) => purgeOrganizationData(db, id), | ||
| 99 | }; | 105 | }; |
| 100 | }; | 106 | }; |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | // - invalid api key -> Unauthorized 401 invalid_api_key | 16 | // - invalid api key -> Unauthorized 401 invalid_api_key |
| 17 | // - api-key org not authorized -> NoOrganization 403 no_organization | 17 | // - api-key org not authorized -> NoOrganization 403 no_organization |
| 18 | // - no/invalid session -> NoOrganization 403 no_organization | 18 | // - no/invalid session -> NoOrganization 403 no_organization |
| 19 | // - session without org header -> NoOrganization 403 no_organization (fail closed) | ||
| 19 | // - session org not authorized -> NoOrganization 403 no_organization | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 20 | // - no auth header -> falls through to the sealed-session path | 21 | // - no auth header -> falls through to the sealed-session path |
| 21 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are | 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are |
| @@ -37,7 +38,13 @@ import { · 15 unmodified lines | |||
| 37 | Unauthorized, | 38 | Unauthorized, |
| 38 | Unavailable, | 39 | Unavailable, |
| 39 | } from "@executor-js/api/server"; | 40 | } from "@executor-js/api/server"; |
| 40 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; | 41 | import type { |
| 42 | FailureRenderingStrategy, | ||
| 43 | IdentityFailure, | ||
| 44 | PlatformPrincipal, | ||
| 45 | Principal, | ||
| 46 | ResolvedPrincipal, | ||
| 47 | } from "@executor-js/api/server"; | ||
| 41 | 48 | ||
| 42 | import { ApiKeyService } from "./api-keys"; | 49 | import { ApiKeyService } from "./api-keys"; |
| 43 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | 50 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; |
| @@ -46,6 +53,7 @@ import { · 2 unmodified lines | |||
| 46 | authorizeOrganization, | 53 | authorizeOrganization, |
| 47 | authorizeOrganizationSelector, | 54 | authorizeOrganizationSelector, |
| 48 | orgSelectorFromRequest, | 55 | orgSelectorFromRequest, |
| 56 | resolveOrganization, | ||
| 49 | } from "./organization"; | 57 | } from "./organization"; |
| 50 | import { UserStoreService } from "./context"; | 58 | import { UserStoreService } from "./context"; |
| 51 | import { sealedSessionDisplayName } from "./middleware"; | 59 | import { sealedSessionDisplayName } from "./middleware"; |
| @@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 34 unmodified lines | |||
| 86 | code: "no_organization", | 94 | code: "no_organization", |
| 87 | message: "No organization in session", | 95 | message: "No organization in session", |
| 88 | }; | 96 | }; |
| 97 | const NO_ORGANIZATION_SELECTOR = { | ||
| 98 | code: "no_organization", | ||
| 99 | message: "No organization selector in request", | ||
| 100 | }; | ||
| 89 | const INVALID_ACCESS_TOKEN = { | 101 | const INVALID_ACCESS_TOKEN = { |
| 90 | code: "invalid_access_token", | 102 | code: "invalid_access_token", |
| 91 | message: "Invalid or expired access token", | 103 | message: "Invalid or expired access token", |
| @@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 6 unmodified lines | |||
| 98 | code: "no_organization", | 110 | code: "no_organization", |
| 99 | message: "No organization in access token", | 111 | message: "No organization in access token", |
| 100 | }; | 112 | }; |
| 101 | |||
| 102 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access | 113 | // A bearer value with three dot-separated segments is a JWT (a WorkOS access |
| 103 | // token from the CLI device-login); anything else is treated as an API key. | 114 | // token from the CLI device-login); anything else is treated as an API key. |
| 104 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). | 115 | // Same discriminator the MCP plane uses (`mcp/auth.ts`). |
| @@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 29 unmodified lines | |||
| 134 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); | 145 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); |
| 135 | 146 | ||
| 136 | return { | 147 | return { |
| 148 | kind: "member", | ||
| 137 | accountId: verified.accountId, | 149 | accountId: verified.accountId, |
| 138 | organizationId: org.id, | 150 | organizationId: org.id, |
| 139 | organizationName: org.name, | 151 | organizationName: org.name, |
| @@ -146,13 +158,49 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => · 6 unmodified lines | |||
| 146 | }); | 158 | }); |
| 147 | 159 | ||
| 148 | /** | 160 | /** |
| 149 | * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT | 161 | * What an ORG-level API key resolves to: the platform view. Deliberately NOT a |
| 150 | * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS | 162 | * `Principal` — a `Principal` names an acting member (`accountId: string`), and |
| 151 | * API key. Returns `null` when there is no `Authorization` header, so the | 163 | * an org key has none. Keeping it a separate shape means no product code path |
| 152 | * caller falls through to the sealed-session path. (Kept the historical name, | 164 | * can accidentally treat it as a member, and the executor built from it binds |
| 153 | * the re-export and resolver tests reference it.) | 165 | * `subject: null` + the read-only tenant reach rather than inventing a subject. |
| 166 | * | ||
| 167 | * The `/admin/*` mount turns this into an executor with `{ tenant: | ||
| 168 | * organizationId, subject: undefined, platformView: true }`. | ||
| 169 | */ | ||
| 170 | export interface PlatformAuth { | ||
| 171 | readonly kind: "platform"; | ||
| 172 | readonly organizationId: string; | ||
| 173 | readonly organizationName: string; | ||
| 174 | readonly organizationSlug?: string; | ||
| 175 | readonly keyId: string; | ||
| 176 | } | ||
| 177 | |||
| 178 | /** A resolved bearer credential: an acting member, or the org-level platform | ||
| 179 | * view. `null` means no `Authorization` header at all. */ | ||
| 180 | export type BearerAuth = Principal | PlatformAuth | null; | ||
| 181 | |||
| 182 | export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth => | ||
| 183 | value !== null && "kind" in value && value.kind === "platform"; | ||
| 184 | |||
| 185 | /** | ||
| 186 | * Resolve a `Bearer` credential into either a member `Principal` or the | ||
| 187 | * org-level {@link PlatformAuth}. Returns `null` when there is no | ||
| 188 | * `Authorization` header, so the caller falls through to the sealed-session | ||
| 189 | * path. | ||
| 190 | * | ||
| 191 | * The org branch does NOT call `authorizeOrganization`: that checks a USER's | ||
| 192 | * live membership, and there is no user here. The key itself is the authority — | ||
| 193 | * WorkOS validated it and reported which org owns it — so the org row is merely | ||
| 194 | * resolved (mirrored on first read) for its name and slug. | ||
| 154 | */ | 195 | */ |
| 155 | export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => | 196 | export const resolveBearerAuth = ( |
| 197 | request: Request, | ||
| 198 | jwt: JwtBearerConfig | null = null, | ||
| 199 | ): Effect.Effect< | ||
| 200 | BearerAuth, | ||
| 201 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | ||
| 202 | WorkOSClient | ApiKeyService | UserStoreService | ||
| 203 | > => | ||
| 156 | Effect.gen(function* () { | 204 | Effect.gen(function* () { |
| 157 | const authHeader = request.headers.get("authorization"); | 205 | const authHeader = request.headers.get("authorization"); |
| 158 | if (!authHeader) return null; | 206 | if (!authHeader) return null; |
| @@ -167,7 +215,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 8 unmodified lines | |||
| 167 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); | 215 | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); |
| 168 | 216 | ||
| 169 | const apiKeys = yield* ApiKeyService; | 217 | const apiKeys = yield* ApiKeyService; |
| 170 | const principal = yield* apiKeys | 218 | const owner = yield* apiKeys |
| 171 | .validate(value) | 219 | .validate(value) |
| 172 | .pipe( | 220 | .pipe( |
| 173 | Effect.catchTag("ApiKeyValidationError", () => | 221 | Effect.catchTag("ApiKeyValidationError", () => |
| @@ -175,13 +223,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 1 unmodified line | |||
| 175 | ), | 223 | ), |
| 176 | ); | 224 | ); |
| 177 | 225 | ||
| 178 | if (!principal) return yield* new Unauthorized(INVALID_API_KEY); | 226 | if (!owner) return yield* new Unauthorized(INVALID_API_KEY); |
| 179 | 227 | ||
| 180 | const org = yield* authorizeOrganization(principal.accountId, principal.organizationId); | 228 | if (owner.scope === "org") { |
| 229 | const org = yield* resolveOrganization(owner.organizationId); | ||
| 230 | return { | ||
| 231 | kind: "platform", | ||
| 232 | organizationId: org.id, | ||
| 233 | organizationName: org.name, | ||
| 234 | ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }), | ||
| 235 | keyId: owner.keyId, | ||
| 236 | } satisfies PlatformAuth; | ||
| 237 | } | ||
| 238 | |||
| 239 | // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the | ||
| 240 | // guard keeps the narrowing honest rather than asserting. | ||
| 241 | if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY); | ||
| 242 | |||
| 243 | const org = yield* authorizeOrganization(owner.accountId, owner.organizationId); | ||
| 181 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); | 244 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); |
| 182 | 245 | ||
| 183 | return { | 246 | return { |
| 184 | accountId: principal.accountId, | 247 | kind: "member", |
| 248 | accountId: owner.accountId, | ||
| 185 | organizationId: org.id, | 249 | organizationId: org.id, |
| 186 | organizationName: org.name, | 250 | organizationName: org.name, |
| 187 | organizationSlug: org.slug, | 251 | organizationSlug: org.slug, |
| @@ -192,6 +256,40 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | · 4 unmodified lines | |||
| 192 | } satisfies Principal; | 256 | } satisfies Principal; |
| 193 | }); | 257 | }); |
| 194 | 258 | ||
| 259 | /** | ||
| 260 | * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral | ||
| 261 | * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared | ||
| 262 | * middleware routes it to the subject-less, read-only platform executor | ||
| 263 | * (refusing non-GET up front). Previously the product plane rejected org keys | ||
| 264 | * outright; serving tenant-level READS to them is deliberate: the catalog, | ||
| 265 | * tools, policies, and org-owned connection listings are tenant-shared answers | ||
| 266 | * a machine credential can honestly receive, while everything subject-bound | ||
| 267 | * stays structurally out of reach (a platform executor binds no subject, so no | ||
| 268 | * member's personal rows resolve). (Kept the historical name; the re-export and | ||
| 269 | * resolver tests reference it.) | ||
| 270 | */ | ||
| 271 | export const resolveApiKeyPrincipal = ( | ||
| 272 | request: Request, | ||
| 273 | jwt: JwtBearerConfig | null = null, | ||
| 274 | ): Effect.Effect< | ||
| 275 | ResolvedPrincipal | null, | ||
| 276 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | ||
| 277 | WorkOSClient | ApiKeyService | UserStoreService | ||
| 278 | > => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const auth = yield* resolveBearerAuth(request, jwt); | ||
| 281 | if (isPlatformAuth(auth)) { | ||
| 282 | return { | ||
| 283 | kind: "platform", | ||
| 284 | organizationId: auth.organizationId, | ||
| 285 | organizationName: auth.organizationName, | ||
| 286 | ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }), | ||
| 287 | keyId: auth.keyId, | ||
| 288 | } satisfies PlatformPrincipal; | ||
| 289 | } | ||
| 290 | return auth; | ||
| 291 | }); | ||
| 292 | |||
| 195 | export const resolveSessionPrincipal = (request: Request) => | 293 | export const resolveSessionPrincipal = (request: Request) => |
| 196 | Effect.gen(function* () { | 294 | Effect.gen(function* () { |
| 197 | const workos = yield* WorkOSClient; | 295 | const workos = yield* WorkOSClient; |
| @@ -199,17 +297,27 @@ export const resolveSessionPrincipal = (request: Request) => · 1 unmodified line | |||
| 199 | if (!session) { | 297 | if (!session) { |
| 200 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 298 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 201 | } | 299 | } |
| 202 | // The console URL's org is the scope authority (sent as a header); the | 300 | // The console URL's org is the scope authority, sent as a header on every |
| 203 | // session's own org is the fallback for non-console callers. Membership is | 301 | // request. FAIL CLOSED when it's missing: the sealed cookie's org is |
| 204 | // re-checked live either way — the header is a selector, not a trust | 302 | // browser-global and pinned to whichever org WorkOS last touched, so |
| 205 | // boundary (see organization.ts). | 303 | // falling back to it silently serves ANOTHER org's data to a multi-org |
| 206 | const selector = orgSelectorFromRequest(request) ?? session.organizationId; | 304 | // user (the wrong-tenant connection-list bug, 2026-07). A header-less |
| 305 | // session call gets a clear 403 instead. Membership is re-checked live — | ||
| 306 | // the header is a selector, not a trust boundary (see organization.ts). | ||
| 307 | // A bare-URL first paint (no org in the path yet) may 403 here; that's | ||
| 308 | // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto | ||
| 309 | // an org slug, the org-keyed atom registry remounts, and everything | ||
| 310 | // refetches with the header. `/account/me` — which deliberately DOES fall | ||
| 311 | // back to the session org to pick that landing org — lives on the account | ||
| 312 | // plane, not here. | ||
| 313 | const selector = orgSelectorFromRequest(request); | ||
| 207 | if (!selector) { | 314 | if (!selector) { |
| 208 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 315 | return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR); |
| 209 | } | 316 | } |
| 210 | const org = yield* authorizeOrganizationSelector(session.userId, selector); | 317 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 211 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 318 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 212 | return { | 319 | return { |
| 320 | kind: "member", | ||
| 213 | accountId: session.userId, | 321 | accountId: session.userId, |
| 214 | organizationId: org.id, | 322 | organizationId: org.id, |
| 215 | organizationName: org.name, | 323 | organizationName: org.name, |
| @@ -236,7 +344,7 @@ export const resolveProtectedPrincipal = ( · 20 unmodified lines | |||
| 236 | request: Request, | 344 | request: Request, |
| 237 | jwt: JwtBearerConfig | null = null, | 345 | jwt: JwtBearerConfig | null = null, |
| 238 | ): Effect.Effect< | 346 | ): Effect.Effect< |
| 239 | Principal, | 347 | ResolvedPrincipal, |
| 240 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, | 348 | Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, |
| 241 | WorkOSClient | ApiKeyService | UserStoreService | 349 | WorkOSClient | ApiKeyService | UserStoreService |
| 242 | > => | 350 | > => |
| @@ -317,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines | |||
| 317 | "service_unavailable", | 425 | "service_unavailable", |
| 318 | "Service temporarily unavailable", | 426 | "Service temporarily unavailable", |
| 319 | ), | 427 | ), |
| 428 | ReadOnlyCredential: renderIdentityFailure( | ||
| 429 | 403, | ||
| 430 | "read_only_credential", | ||
| 431 | "Organization API keys are read-only", | ||
| 432 | ), | ||
| 320 | }), | 433 | }), |
| 321 | ), | 434 | ), |
| 322 | }; | 435 | }; |
| @@ -1,6 +1,287 @@ | |||
| 1 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | ||
| 2 | import type { AddressInfo } from "node:net"; | ||
| 3 | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import { env } from "cloudflare:workers"; | ||
| 6 | import { Effect, Schema } from "effect"; | ||
| 7 | import { defaults as ironDefaults, seal as sealIron } from "iron-webcrypto"; | ||
| 8 | import { | ||
| 9 | SignJWT, | ||
| 10 | exportJWK, | ||
| 11 | generateKeyPair, | ||
| 12 | type JSONWebKeySet, | ||
| 13 | type JWK, | ||
| 14 | type KeyLike, | ||
| 15 | } from "jose"; | ||
| 16 | |||
| 17 | import { WorkOSClient, collectRawWorkOSList, collectWorkOSList } from "./workos"; | ||
| 18 | |||
| 19 | const COOKIE_PASSWORD = "test_cookie_password_at_least_32_chars!"; | ||
| 20 | const CLIENT_ID = "client_test"; | ||
| 21 | const API_KEY = "sk_test"; | ||
| 22 | const USER = { | ||
| 23 | id: "user_test", | ||
| 24 | email: "test@example.com", | ||
| 25 | firstName: "Test", | ||
| 26 | lastName: "User", | ||
| 27 | profilePictureUrl: "https://example.com/avatar.png", | ||
| 28 | } as const; | ||
| 29 | |||
| 30 | interface Keypair { | ||
| 31 | readonly kid: string; | ||
| 32 | readonly publicJwk: JWK; | ||
| 33 | readonly privateKey: KeyLike; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface RecordedRequest { | ||
| 37 | readonly method: string; | ||
| 38 | readonly path: string; | ||
| 39 | readonly body: unknown; | ||
| 40 | } | ||
| 41 | |||
| 42 | interface WorkOSStub { | ||
| 43 | readonly baseUrl: string; | ||
| 44 | readonly requests: () => readonly RecordedRequest[]; | ||
| 45 | readonly setKeys: (keys: ReadonlyArray<JWK>) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | const generateKeypair = async (kid: string): Promise<Keypair> => { | ||
| 49 | const { publicKey, privateKey } = await generateKeyPair("RS256"); | ||
| 50 | const jwk = await exportJWK(publicKey); | ||
| 51 | return { kid, publicJwk: { ...jwk, kid, alg: "RS256" }, privateKey }; | ||
| 52 | }; | ||
| 53 | |||
| 54 | const signAccessToken = ( | ||
| 55 | keypair: Keypair, | ||
| 56 | claims: { | ||
| 57 | readonly subject?: string; | ||
| 58 | readonly organizationId?: string; | ||
| 59 | readonly sessionId?: string; | ||
| 60 | readonly expiresIn?: string | number; | ||
| 61 | } = {}, | ||
| 62 | ) => { | ||
| 63 | const jwt = new SignJWT({ | ||
| 64 | org_id: claims.organizationId ?? "org_test", | ||
| 65 | sid: claims.sessionId ?? "session_test", | ||
| 66 | }) | ||
| 67 | .setProtectedHeader({ alg: "RS256", kid: keypair.kid }) | ||
| 68 | .setSubject(claims.subject ?? USER.id) | ||
| 69 | .setIssuedAt(); | ||
| 70 | |||
| 71 | return ( | ||
| 72 | typeof claims.expiresIn === "number" | ||
| 73 | ? jwt.setExpirationTime(claims.expiresIn) | ||
| 74 | : jwt.setExpirationTime(claims.expiresIn ?? "5m") | ||
| 75 | ).sign(keypair.privateKey); | ||
| 76 | }; | ||
| 77 | |||
| 78 | const sealSession = async (accessToken: string, refreshToken = "refresh_test"): Promise<string> => | ||
| 79 | `${await sealIron( | ||
| 80 | { | ||
| 81 | accessToken, | ||
| 82 | refreshToken, | ||
| 83 | user: USER, | ||
| 84 | }, | ||
| 85 | { id: "1", secret: COOKIE_PASSWORD }, | ||
| 86 | { ...ironDefaults, ttl: 0, encode: JSON.stringify }, | ||
| 87 | )}~2`; | ||
| 88 | |||
| 89 | const workosUserResponse = { | ||
| 90 | object: "user", | ||
| 91 | id: USER.id, | ||
| 92 | email: USER.email, | ||
| 93 | email_verified: true, | ||
| 94 | profile_picture_url: USER.profilePictureUrl, | ||
| 95 | first_name: USER.firstName, | ||
| 96 | last_name: USER.lastName, | ||
| 97 | last_sign_in_at: null, | ||
| 98 | locale: null, | ||
| 99 | created_at: "2026-07-09T00:00:00.000Z", | ||
| 100 | updated_at: "2026-07-09T00:00:00.000Z", | ||
| 101 | external_id: null, | ||
| 102 | metadata: {}, | ||
| 103 | } as const; | ||
| 104 | |||
| 105 | const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 106 | |||
| 107 | const readJsonBody = async (request: IncomingMessage): Promise<unknown> => { | ||
| 108 | const chunks: Buffer[] = []; | ||
| 109 | for await (const chunk of request) { | ||
| 110 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 111 | } | ||
| 112 | const body = Buffer.concat(chunks).toString("utf8"); | ||
| 113 | return body ? decodeJsonBody(body) : null; | ||
| 114 | }; | ||
| 115 | |||
| 116 | const writeJson = (response: ServerResponse, status: number, body: unknown): void => { | ||
| 117 | response.writeHead(status, { "content-type": "application/json" }); | ||
| 118 | response.end(JSON.stringify(body)); | ||
| 119 | }; | ||
| 120 | |||
| 121 | const withWorkOSStub = async <A>( | ||
| 122 | keypair: Keypair, | ||
| 123 | use: (stub: WorkOSStub) => Promise<A>, | ||
| 124 | ): Promise<A> => { | ||
| 125 | let keys: ReadonlyArray<JWK> = [keypair.publicJwk]; | ||
| 126 | const requests: RecordedRequest[] = []; | ||
| 127 | const server = createServer((request, response) => { | ||
| 128 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: Node's request callback cannot await, so the fixture converts async failures into a stable HTTP 500 | ||
| 129 | void (async () => { | ||
| 130 | const path = request.url ?? "/"; | ||
| 131 | const body = request.method === "POST" ? await readJsonBody(request) : null; | ||
| 132 | requests.push({ method: request.method ?? "GET", path, body }); | ||
| 133 | |||
| 134 | if (request.method === "GET" && path === `/sso/jwks/${CLIENT_ID}`) { | ||
| 135 | writeJson(response, 200, { keys: keys.map((key) => ({ ...key })) } satisfies JSONWebKeySet); | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (request.method === "POST" && path === "/user_management/authenticate") { | ||
| 140 | const refreshed = await signAccessToken(keypair, { | ||
| 141 | sessionId: "session_refreshed", | ||
| 142 | }); | ||
| 143 | writeJson(response, 200, { | ||
| 144 | user: workosUserResponse, | ||
| 145 | organization_id: "org_test", | ||
| 146 | access_token: refreshed, | ||
| 147 | refresh_token: "refresh_next", | ||
| 148 | authentication_method: "Password", | ||
| 149 | }); | ||
| 150 | return; | ||
| 151 | } | ||
| 152 | |||
| 153 | writeJson(response, 404, { error: "unexpected request" }); | ||
| 154 | })().catch((_error: unknown) => { | ||
| 155 | writeJson(response, 500, { | ||
| 156 | error: "fixture server failed", | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | }); | ||
| 160 | |||
| 161 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 162 | const address = server.address() as AddressInfo; | ||
| 163 | const stub: WorkOSStub = { | ||
| 164 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 165 | requests: () => requests, | ||
| 166 | setKeys: (next) => { | ||
| 167 | keys = next; | ||
| 168 | }, | ||
| 169 | }; | ||
| 170 | |||
| 171 | return use(stub).finally(() => { | ||
| 172 | server.closeAllConnections(); | ||
| 173 | server.close(); | ||
| 174 | }); | ||
| 175 | }; | ||
| 2 | 176 | ||
| 3 | import { collectRawWorkOSList, collectWorkOSList } from "./workos"; | 177 | const runAuthenticate = (sessionData: string, baseUrl: string) => { |
| 178 | Object.assign(env, { | ||
| 179 | WORKOS_API_KEY: API_KEY, | ||
| 180 | WORKOS_CLIENT_ID: CLIENT_ID, | ||
| 181 | WORKOS_COOKIE_PASSWORD: COOKIE_PASSWORD, | ||
| 182 | WORKOS_API_URL: baseUrl, | ||
| 183 | }); | ||
| 184 | |||
| 185 | return Effect.runPromise( | ||
| 186 | Effect.gen(function* () { | ||
| 187 | const workos = yield* WorkOSClient; | ||
| 188 | return yield* workos.authenticateSealedSession(sessionData); | ||
| 189 | }).pipe(Effect.provide(WorkOSClient.Default)), | ||
| 190 | ); | ||
| 191 | }; | ||
| 192 | |||
| 193 | describe("authenticateSealedSession", () => { | ||
| 194 | it("validates a sealed session locally with the cached JWKS", async () => { | ||
| 195 | const keypair = await generateKeypair("k_valid"); | ||
| 196 | await withWorkOSStub(keypair, async (stub) => { | ||
| 197 | const token = await signAccessToken(keypair, { | ||
| 198 | organizationId: "org_test", | ||
| 199 | sessionId: "session_valid", | ||
| 200 | }); | ||
| 201 | const session = await sealSession(token); | ||
| 202 | |||
| 203 | const result = await runAuthenticate(session, stub.baseUrl); | ||
| 204 | |||
| 205 | expect(result).toEqual({ | ||
| 206 | userId: USER.id, | ||
| 207 | email: USER.email, | ||
| 208 | firstName: USER.firstName, | ||
| 209 | lastName: USER.lastName, | ||
| 210 | avatarUrl: USER.profilePictureUrl, | ||
| 211 | organizationId: "org_test", | ||
| 212 | sessionId: "session_valid", | ||
| 213 | refreshedSession: undefined, | ||
| 214 | }); | ||
| 215 | expect(stub.requests()).toEqual([ | ||
| 216 | { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, | ||
| 217 | ]); | ||
| 218 | }); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("reuses the module cached JWKS for a second sealed session", async () => { | ||
| 222 | const keypair = await generateKeypair("k_cached"); | ||
| 223 | await withWorkOSStub(keypair, async (stub) => { | ||
| 224 | const first = await sealSession( | ||
| 225 | await signAccessToken(keypair, { | ||
| 226 | sessionId: "session_first", | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | const second = await sealSession( | ||
| 230 | await signAccessToken(keypair, { | ||
| 231 | sessionId: "session_second", | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | |||
| 235 | await runAuthenticate(first, stub.baseUrl); | ||
| 236 | const afterFirst = stub.requests().length; | ||
| 237 | const result = await runAuthenticate(second, stub.baseUrl); | ||
| 238 | |||
| 239 | expect(afterFirst).toBe(1); | ||
| 240 | expect(result?.sessionId).toBe("session_second"); | ||
| 241 | expect(stub.requests()).toEqual([ | ||
| 242 | { method: "GET", path: `/sso/jwks/${CLIENT_ID}`, body: null }, | ||
| 243 | ]); | ||
| 244 | }); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("falls through to SDK refresh when the access token is expired", async () => { | ||
| 248 | const keypair = await generateKeypair("k_expired"); | ||
| 249 | await withWorkOSStub(keypair, async (stub) => { | ||
| 250 | const expiredAt = Math.floor(Date.now() / 1000) - 60; | ||
| 251 | const expired = await signAccessToken(keypair, { | ||
| 252 | expiresIn: expiredAt, | ||
| 253 | sessionId: "session_expired", | ||
| 254 | }); | ||
| 255 | const session = await sealSession(expired, "refresh_expired"); | ||
| 256 | |||
| 257 | const result = await runAuthenticate(session, stub.baseUrl); | ||
| 258 | |||
| 259 | expect(result?.sessionId).toBe("session_refreshed"); | ||
| 260 | expect(result?.refreshedSession).toEqual(expect.any(String)); | ||
| 261 | expect(stub.requests().map((request) => [request.method, request.path])).toEqual([ | ||
| 262 | ["GET", `/sso/jwks/${CLIENT_ID}`], | ||
| 263 | ["POST", "/user_management/authenticate"], | ||
| 264 | ]); | ||
| 265 | expect(stub.requests()[1]?.body).toMatchObject({ | ||
| 266 | grant_type: "refresh_token", | ||
| 267 | client_id: CLIENT_ID, | ||
| 268 | client_secret: API_KEY, | ||
| 269 | refresh_token: "refresh_expired", | ||
| 270 | organization_id: "org_test", | ||
| 271 | }); | ||
| 272 | }); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("returns null for garbage session data", async () => { | ||
| 276 | const keypair = await generateKeypair("k_garbage"); | ||
| 277 | await withWorkOSStub(keypair, async (stub) => { | ||
| 278 | const result = await runAuthenticate("not-a-sealed-session", stub.baseUrl); | ||
| 279 | |||
| 280 | expect(result).toBeNull(); | ||
| 281 | expect(stub.requests()).toEqual([]); | ||
| 282 | }); | ||
| 283 | }); | ||
| 284 | }); | ||
| 4 | 285 | ||
| 5 | describe("collectWorkOSList", () => { | 286 | describe("collectWorkOSList", () => { |
| 6 | it("collects memberships beyond the first WorkOS page", async () => { | 287 | it("collects memberships beyond the first WorkOS page", async () => { |
| @@ -3,13 +3,23 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | 5 | import { env } from "cloudflare:workers"; |
| 6 | import { Context, Data, Effect, Layer, Option, Schema } from "effect"; | 6 | import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"; |
| 7 | import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; | 7 | import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; |
| 8 | import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; | ||
| 9 | import { decodeJwt, jwtVerify } from "jose"; | ||
| 10 | import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors"; | ||
| 8 | import { parseCookie } from "./cookies"; | 11 | import { parseCookie } from "./cookies"; |
| 9 | import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; | 12 | import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache"; |
| 13 | import { | ||
| 14 | ServiceAdapterError, | ||
| 15 | tryPromiseService, | ||
| 16 | withServiceLogging, | ||
| 17 | workosErrorFromFailure, | ||
| 18 | } from "./errors"; | ||
| 10 | 19 | ||
| 11 | const COOKIE_NAME = "wos-session"; | 20 | const COOKIE_NAME = "wos-session"; |
| 12 | const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters"; | 21 | const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters"; |
| 22 | const WORKOS_SEAL_VERSION_DELIMITER = "~"; | ||
| 13 | 23 | ||
| 14 | type RawWorkOS = WorkOS & { | 24 | type RawWorkOS = WorkOS & { |
| 15 | readonly get: ( | 25 | readonly get: ( |
| @@ -59,6 +69,239 @@ const RawWorkOSListResponse = Schema.Struct({ · 43 unmodified lines | |||
| 59 | 69 | ||
| 60 | const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); | 70 | const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); |
| 61 | 71 | ||
| 72 | const SealedSessionUser = Schema.Struct({ | ||
| 73 | id: Schema.String, | ||
| 74 | email: Schema.String, | ||
| 75 | firstName: Schema.NullOr(Schema.String), | ||
| 76 | lastName: Schema.NullOr(Schema.String), | ||
| 77 | profilePictureUrl: Schema.NullOr(Schema.String), | ||
| 78 | }); | ||
| 79 | |||
| 80 | const SealedSessionPayload = Schema.Struct({ | ||
| 81 | accessToken: Schema.String, | ||
| 82 | refreshToken: Schema.String, | ||
| 83 | user: SealedSessionUser, | ||
| 84 | authenticationMethod: Schema.optional(Schema.Unknown), | ||
| 85 | impersonator: Schema.optional(Schema.Unknown), | ||
| 86 | }); | ||
| 87 | |||
| 88 | const decodeSealedSessionPayload = Schema.decodeUnknownOption(SealedSessionPayload); | ||
| 89 | |||
| 90 | const JwtClaims = Schema.Struct({ | ||
| 91 | sid: Schema.String, | ||
| 92 | org_id: Schema.optional(Schema.String), | ||
| 93 | }); | ||
| 94 | |||
| 95 | const decodeJwtClaims = Schema.decodeUnknownOption(JwtClaims); | ||
| 96 | |||
| 97 | const LegacySealedSessionPayload = Schema.Struct({ | ||
| 98 | persistent: Schema.Unknown, | ||
| 99 | }); | ||
| 100 | |||
| 101 | const decodeLegacySealedSessionPayload = Schema.decodeUnknownOption(LegacySealedSessionPayload); | ||
| 102 | |||
| 103 | type SealedSessionPayload = typeof SealedSessionPayload.Type; | ||
| 104 | |||
| 105 | type LocalSessionVerification = | ||
| 106 | | { | ||
| 107 | readonly _tag: "Valid"; | ||
| 108 | readonly session: SealedSessionPayload; | ||
| 109 | readonly organizationId: string | undefined; | ||
| 110 | readonly sessionId: string; | ||
| 111 | } | ||
| 112 | | { readonly _tag: "Refresh" } | ||
| 113 | | { readonly _tag: "InvalidCookie" }; | ||
| 114 | |||
| 115 | class LocalSessionCookieError extends Data.TaggedError("LocalSessionCookieError")<{ | ||
| 116 | readonly cause: unknown; | ||
| 117 | }> {} | ||
| 118 | |||
| 119 | const isLocalSessionValid = Predicate.isTagged("Valid") as ( | ||
| 120 | value: LocalSessionVerification, | ||
| 121 | ) => value is Extract<LocalSessionVerification, { readonly _tag: "Valid" }>; | ||
| 122 | |||
| 123 | const isLocalSessionInvalidCookie = Predicate.isTagged("InvalidCookie") as ( | ||
| 124 | value: LocalSessionVerification, | ||
| 125 | ) => value is Extract<LocalSessionVerification, { readonly _tag: "InvalidCookie" }>; | ||
| 126 | |||
| 127 | const ErrorWithCode = Schema.Struct({ code: Schema.String }); | ||
| 128 | const isErrorWithCode = Schema.is(ErrorWithCode); | ||
| 129 | |||
| 130 | const isJwtVerificationFailure = (cause: unknown): boolean => | ||
| 131 | isErrorWithCode(cause) && | ||
| 132 | (cause.code.startsWith("ERR_JWT_") || | ||
| 133 | cause.code.startsWith("ERR_JWS_") || | ||
| 134 | cause.code === JWKSNoMatchingKey.code); | ||
| 135 | |||
| 136 | const isJwksSystemFailure = (cause: unknown): boolean => | ||
| 137 | isErrorWithCode(cause) && (cause.code === JWKSTimeout.code || cause.code === JWKSInvalid.code); | ||
| 138 | |||
| 139 | const parseWorkOSSeal = ( | ||
| 140 | seal: string, | ||
| 141 | ): { readonly sealWithoutVersion: string; readonly tokenVersion: number | null } => { | ||
| 142 | const [sealWithoutVersion = "", tokenVersionAsString] = seal.split(WORKOS_SEAL_VERSION_DELIMITER); | ||
| 143 | return { | ||
| 144 | sealWithoutVersion, | ||
| 145 | tokenVersion: | ||
| 146 | tokenVersionAsString === undefined ? null : Number.parseInt(tokenVersionAsString, 10), | ||
| 147 | }; | ||
| 148 | }; | ||
| 149 | |||
| 150 | const unsealWorkOSSession = async ( | ||
| 151 | sessionData: string, | ||
| 152 | cookiePassword: string, | ||
| 153 | ): Promise<unknown> => { | ||
| 154 | const { sealWithoutVersion, tokenVersion } = parseWorkOSSeal(sessionData); | ||
| 155 | const data = | ||
| 156 | (await unsealIron(sealWithoutVersion, { 1: cookiePassword }, { ...ironDefaults, ttl: 0 })) ?? | ||
| 157 | {}; | ||
| 158 | // Mirrors the SDK's unsealData version handling: current (v2) seals hold the | ||
| 159 | // payload directly, OTHER versioned seals nest it under `persistent`, and an | ||
| 160 | // unversioned seal is the payload itself. | ||
| 161 | if (tokenVersion === 2 || tokenVersion === null) return data; | ||
| 162 | return Option.match(decodeLegacySealedSessionPayload(data), { | ||
| 163 | onNone: () => data, | ||
| 164 | onSome: (legacy) => legacy.persistent, | ||
| 165 | }); | ||
| 166 | }; | ||
| 167 | |||
| 168 | const getWorkOSSessionJwks = (() => { | ||
| 169 | const resolvers = new Map<string, CachedRemoteJWKSet>(); | ||
| 170 | return (url: URL): CachedRemoteJWKSet => { | ||
| 171 | const key = url.toString(); | ||
| 172 | const existing = resolvers.get(key); | ||
| 173 | if (existing) return existing; | ||
| 174 | const created = createCachedRemoteJWKSet(url); | ||
| 175 | resolvers.set(key, created); | ||
| 176 | return created; | ||
| 177 | }; | ||
| 178 | })(); | ||
| 179 | |||
| 180 | const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) => | ||
| 181 | Effect.tryPromise({ | ||
| 182 | try: () => jwtVerify(accessToken, jwks), | ||
| 183 | catch: (cause) => new ServiceAdapterError({ cause }), | ||
| 184 | }); | ||
| 185 | |||
| 186 | const verifyJwtWithRefreshRetry = ( | ||
| 187 | accessToken: string, | ||
| 188 | jwks: CachedRemoteJWKSet, | ||
| 189 | ): Effect.Effect<boolean, ServiceAdapterError> => | ||
| 190 | verifyJwtOnce(accessToken, jwks).pipe( | ||
| 191 | Effect.as(true), | ||
| 192 | Effect.catchTag("ServiceAdapterError", (error) => { | ||
| 193 | if (isErrorWithCode(error.cause) && error.cause.code === JWKSNoMatchingKey.code) { | ||
| 194 | return Effect.sync(() => jwks.forceRefresh()).pipe( | ||
| 195 | Effect.flatMap(() => verifyJwtOnce(accessToken, jwks)), | ||
| 196 | Effect.as(true), | ||
| 197 | Effect.catchTag("ServiceAdapterError", (retryError) => | ||
| 198 | isJwtVerificationFailure(retryError.cause) | ||
| 199 | ? Effect.succeed(false) | ||
| 200 | : Effect.fail(retryError), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | if (isJwtVerificationFailure(error.cause)) return Effect.succeed(false); | ||
| 205 | if (isJwksSystemFailure(error.cause)) return Effect.fail(error); | ||
| 206 | return Effect.fail(error); | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | |||
| 210 | const verifySealedSessionLocally = ( | ||
| 211 | sessionData: string, | ||
| 212 | cookiePassword: string, | ||
| 213 | jwks: CachedRemoteJWKSet, | ||
| 214 | ): Effect.Effect<LocalSessionVerification, ServiceAdapterError> => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | // Phase timings, not just child spans. `local_verify` is a leaf in | ||
| 217 | // production traces, so a ~3.3s verify has nothing under it to blame — | ||
| 218 | // and it stayed 3.3s after the JWKS fetch was eliminated entirely | ||
| 219 | // (jwks.fetch_count == 0), so the cost is one of the phases below. Under | ||
| 220 | // workerd `Date.now()` only advances at I/O boundaries, which is exactly | ||
| 221 | // what makes a raw span duration misleading here: recording each phase | ||
| 222 | // explicitly says which await the wall-clock actually crossed. | ||
| 223 | const verifyStartedAt = Date.now(); | ||
| 224 | |||
| 225 | const unsealStartedAt = Date.now(); | ||
| 226 | const unsealed = yield* Effect.tryPromise({ | ||
| 227 | try: () => unsealWorkOSSession(sessionData, cookiePassword), | ||
| 228 | catch: (cause) => new LocalSessionCookieError({ cause }), | ||
| 229 | }).pipe( | ||
| 230 | Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), | ||
| 231 | Effect.withSpan("workos.session.unseal"), | ||
| 232 | ); | ||
| 233 | const unsealMs = Date.now() - unsealStartedAt; | ||
| 234 | yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs }); | ||
| 235 | if (!unsealed) return { _tag: "InvalidCookie" }; | ||
| 236 | |||
| 237 | const decodeStartedAt = Date.now(); | ||
| 238 | const session = Option.match(decodeSealedSessionPayload(unsealed), { | ||
| 239 | onNone: (): SealedSessionPayload | null => null, | ||
| 240 | onSome: (payload) => payload, | ||
| 241 | }); | ||
| 242 | yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt }); | ||
| 243 | if (!session) return { _tag: "InvalidCookie" }; | ||
| 244 | |||
| 245 | // Snapshot the JWKS cache around the verify so the `local_verify` span | ||
| 246 | // says whether THIS verify was a warm-cache signature check or paid for a | ||
| 247 | // live upstream JWKS fetch. The Aug 2026 latency regression was the cache | ||
| 248 | // silently missing on most verifies, and no span attribute distinguished | ||
| 249 | // the two paths. | ||
| 250 | const jwksBefore = jwks.inspect(); | ||
| 251 | // Entry-state annotation goes on BEFORE the verify so a failing verify | ||
| 252 | // (the case worth debugging) still records whether the cache was warm. | ||
| 253 | yield* Effect.annotateCurrentSpan({ | ||
| 254 | "jwks.cache_populated_at_start": jwksBefore.hasJwks, | ||
| 255 | ...(jwksBefore.fetchedAt === null | ||
| 256 | ? {} | ||
| 257 | : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }), | ||
| 258 | }); | ||
| 259 | const jwtStartedAt = Date.now(); | ||
| 260 | const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe( | ||
| 261 | Effect.withSpan("workos.session.jwt_verify"), | ||
| 262 | Effect.onExit(() => { | ||
| 263 | const jwksAfter = jwks.inspect(); | ||
| 264 | const finishedAt = Date.now(); | ||
| 265 | return Effect.annotateCurrentSpan({ | ||
| 266 | "verify.jwt_ms": finishedAt - jwtStartedAt, | ||
| 267 | "verify.total_ms": finishedAt - verifyStartedAt, | ||
| 268 | // Blocking, not total: under stale-while-revalidate a background | ||
| 269 | // refresh moves `fetchCount` without costing this verify anything. | ||
| 270 | // Attribute latency to what the caller actually waited on. | ||
| 271 | "jwks.fetched_during_verify": | ||
| 272 | jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount, | ||
| 273 | "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount, | ||
| 274 | "jwks.fetch_count": jwksAfter.fetchCount, | ||
| 275 | "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount, | ||
| 276 | "jwks.fetch_failure_count": jwksAfter.fetchFailureCount, | ||
| 277 | ...(jwksAfter.lastFetchDurationMs === null | ||
| 278 | ? {} | ||
| 279 | : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }), | ||
| 280 | // Splits the ~3.4s that sits inside jwt_verify with zero upstream | ||
| 281 | // fetches: the cross-isolate store read (I/O) vs WebCrypto key | ||
| 282 | // import vs the signature check itself. | ||
| 283 | ...(jwksAfter.lastStoreReadMs === null | ||
| 284 | ? {} | ||
| 285 | : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }), | ||
| 286 | ...(jwksAfter.lastResolveMs === null | ||
| 287 | ? {} | ||
| 288 | : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }), | ||
| 289 | }); | ||
| 290 | }), | ||
| 291 | ); | ||
| 292 | if (!verified) return { _tag: "Refresh" }; | ||
| 293 | |||
| 294 | const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken))); | ||
| 295 | if (!claims) return { _tag: "Refresh" }; | ||
| 296 | |||
| 297 | return { | ||
| 298 | _tag: "Valid", | ||
| 299 | session, | ||
| 300 | organizationId: claims.org_id, | ||
| 301 | sessionId: claims.sid, | ||
| 302 | }; | ||
| 303 | }); | ||
| 304 | |||
| 62 | const completedListMetadata = { | 305 | const completedListMetadata = { |
| 63 | before: null, | 306 | before: null, |
| 64 | after: null, | 307 | after: null, |
| @@ -145,12 +388,24 @@ const make = Effect.gen(function* () { · 80 unmodified lines | |||
| 145 | }); | 388 | }); |
| 146 | } | 389 | } |
| 147 | 390 | ||
| 148 | const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) }); | 391 | const workos = new WorkOS({ |
| 149 | 392 | apiKey, | |
| 150 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => | 393 | clientId, |
| 394 | ...workosApiUrlOptions(env.WORKOS_API_URL), | ||
| 395 | }); | ||
| 396 | const sessionJwks = getWorkOSSessionJwks(new URL(workos.userManagement.getJwksUrl(clientId))); | ||
| 397 | |||
| 398 | // The public `WorkOSError` carries the upstream HTTP status when the SDK | ||
| 399 | // exception had one (all its typed exceptions do), so consumers can tell a | ||
| 400 | // definitive WorkOS denial (401/403/404 — fail closed) from a transient | ||
| 401 | // failure (429/5xx/network — retryable). | ||
| 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>) => | ||
| 151 | withServiceLogging( | 406 | withServiceLogging( |
| 152 | "workos", | 407 | `workos.${op}`, |
| 153 | () => new WorkOSError(), | 408 | workosErrorFromFailure, |
| 154 | tryPromiseService(() => fn(workos)), | 409 | tryPromiseService(() => fn(workos)), |
| 155 | ); | 410 | ); |
| 156 | 411 | ||
| @@ -163,25 +418,29 @@ const make = Effect.gen(function* () { · 6 unmodified lines | |||
| 163 | cookiePassword, | 418 | cookiePassword, |
| 164 | }); | 419 | }); |
| 165 | 420 | ||
| 166 | const result = yield* use(() => session.authenticate()); | 421 | const local = yield* withServiceLogging( |
| 422 | "workos.session.local_verify", | ||
| 423 | workosErrorFromFailure, | ||
| 424 | verifySealedSessionLocally(sessionData, cookiePassword, sessionJwks), | ||
| 425 | ); | ||
| 167 | 426 | ||
| 168 | if (result.authenticated) { | 427 | if (isLocalSessionValid(local)) { |
| 169 | return { | 428 | return { |
| 170 | userId: result.user.id, | 429 | userId: local.session.user.id, |
| 171 | email: result.user.email, | 430 | email: local.session.user.email, |
| 172 | firstName: result.user.firstName, | 431 | firstName: local.session.user.firstName, |
| 173 | lastName: result.user.lastName, | 432 | lastName: local.session.user.lastName, |
| 174 | avatarUrl: result.user.profilePictureUrl, | 433 | avatarUrl: local.session.user.profilePictureUrl, |
| 175 | organizationId: result.organizationId, | 434 | organizationId: local.organizationId, |
| 176 | sessionId: result.sessionId, | 435 | sessionId: local.sessionId, |
| 177 | refreshedSession: undefined as string | undefined, | 436 | refreshedSession: undefined as string | undefined, |
| 178 | }; | 437 | }; |
| 179 | } | 438 | } |
| 180 | 439 | ||
| 181 | if (result.reason === "no_session_cookie_provided") return null; | 440 | if (isLocalSessionInvalidCookie(local)) return null; |
| 182 | 441 | ||
| 183 | // Try refreshing | 442 | // Try refreshing |
| 184 | const refreshed = yield* use(() => session.refresh()).pipe( | 443 | const refreshed = yield* use("session.refresh", () => session.refresh()).pipe( |
| 185 | Effect.orElseSucceed(() => ({ authenticated: false as const })), | 444 | Effect.orElseSucceed(() => ({ authenticated: false as const })), |
| 186 | ); | 445 | ); |
| 187 | 446 | ||
| @@ -210,7 +469,7 @@ const make = Effect.gen(function* () { · 22 unmodified lines | |||
| 210 | }), | 469 | }), |
| 211 | 470 | ||
| 212 | authenticateWithCode: (code: string) => | 471 | authenticateWithCode: (code: string) => |
| 213 | use((wos) => | 472 | use("userManagement.authenticateWithCode", (wos) => |
| 214 | wos.userManagement.authenticateWithCode({ | 473 | wos.userManagement.authenticateWithCode({ |
| 215 | code, | 474 | code, |
| 216 | clientId, | 475 | clientId, |
| @@ -220,11 +479,13 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 220 | 479 | ||
| 221 | /** Create a new organization in WorkOS. */ | 480 | /** Create a new organization in WorkOS. */ |
| 222 | createOrganization: (name: string) => | 481 | createOrganization: (name: string) => |
| 223 | use((wos) => wos.organizations.createOrganization({ name })), | 482 | use("organizations.createOrganization", (wos) => |
| 483 | wos.organizations.createOrganization({ name }), | ||
| 484 | ), | ||
| 224 | 485 | ||
| 225 | /** Add a user to an organization. */ | 486 | /** Add a user to an organization. */ |
| 226 | createMembership: (organizationId: string, userId: string, roleSlug?: string) => | 487 | createMembership: (organizationId: string, userId: string, roleSlug?: string) => |
| 227 | use((wos) => | 488 | use("userManagement.createOrganizationMembership", (wos) => |
| 228 | wos.userManagement.createOrganizationMembership({ | 489 | wos.userManagement.createOrganizationMembership({ |
| 229 | organizationId, | 490 | organizationId, |
| 230 | userId, | 491 | userId, |
| @@ -234,7 +495,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 234 | 495 | ||
| 235 | /** List organization memberships for a user. */ | 496 | /** List organization memberships for a user. */ |
| 236 | listUserMemberships: (userId: string) => | 497 | listUserMemberships: (userId: string) => |
| 237 | use(async (wos) => | 498 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 238 | collectWorkOSList( | 499 | collectWorkOSList( |
| 239 | await wos.userManagement.listOrganizationMemberships({ | 500 | await wos.userManagement.listOrganizationMemberships({ |
| 240 | userId, | 501 | userId, |
| @@ -253,13 +514,53 @@ const make = Effect.gen(function* () { · 12 unmodified lines | |||
| 253 | sessionData, | 514 | sessionData, |
| 254 | cookiePassword, | 515 | cookiePassword, |
| 255 | }); | 516 | }); |
| 256 | const refreshed = yield* use(() => | 517 | const refreshed = yield* use("session.refresh", () => |
| 257 | session.refresh(organizationId ? { organizationId } : undefined), | 518 | session.refresh(organizationId ? { organizationId } : undefined), |
| 258 | ); | 519 | ); |
| 259 | if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; | 520 | if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; |
| 260 | return refreshed.sealedSession ?? null; | 521 | return refreshed.sealedSession ?? null; |
| 261 | }), | 522 | }), |
| 262 | 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 | |||
| 263 | /** | 564 | /** |
| 264 | * Authenticate a sealed session string. Returns the user info plus | 565 | * Authenticate a sealed session string. Returns the user info plus |
| 265 | * any refreshed session that needs to be set on the response. | 566 | * any refreshed session that needs to be set on the response. |
| @@ -282,10 +583,13 @@ const make = Effect.gen(function* () { · 16 unmodified lines | |||
| 282 | * auth/api-keys.ts. | 583 | * auth/api-keys.ts. |
| 283 | */ | 584 | */ |
| 284 | validateApiKey: (value: string) => | 585 | validateApiKey: (value: string) => |
| 285 | use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>), | 586 | use( |
| 587 | "apiKeys.validateApiKey", | ||
| 588 | (wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>, | ||
| 589 | ), | ||
| 286 | 590 | ||
| 287 | listUserApiKeys: (userId: string, organizationId: string) => | 591 | listUserApiKeys: (userId: string, organizationId: string) => |
| 288 | use(async (wos) => { | 592 | use("userManagement.listUserApiKeys", async (wos) => { |
| 289 | const raw = wos as RawWorkOS; | 593 | const raw = wos as RawWorkOS; |
| 290 | return collectRawWorkOSList(async (after) => { | 594 | return collectRawWorkOSList(async (after) => { |
| 291 | const response = await raw.get(`/user_management/users/${userId}/api_keys`, { | 595 | const response = await raw.get(`/user_management/users/${userId}/api_keys`, { |
| @@ -300,7 +604,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 300 | }), | 604 | }), |
| 301 | 605 | ||
| 302 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => | 606 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => |
| 303 | use(async (wos) => { | 607 | use("userManagement.createUserApiKey", async (wos) => { |
| 304 | const raw = wos as RawWorkOS; | 608 | const raw = wos as RawWorkOS; |
| 305 | const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { | 609 | const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { |
| 306 | name: params.name, | 610 | name: params.name, |
| @@ -309,11 +613,40 @@ const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 309 | return response.data; | 613 | return response.data; |
| 310 | }), | 614 | }), |
| 311 | 615 | ||
| 312 | 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)), | ||
| 313 | 646 | ||
| 314 | /** List organization memberships with user details. */ | 647 | /** List organization memberships with user details. */ |
| 315 | listOrgMembers: (organizationId: string) => | 648 | listOrgMembers: (organizationId: string) => |
| 316 | use(async (wos) => | 649 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 317 | collectWorkOSList( | 650 | collectWorkOSList( |
| 318 | await wos.userManagement.listOrganizationMemberships({ | 651 | await wos.userManagement.listOrganizationMemberships({ |
| 319 | organizationId, | 652 | organizationId, |
| @@ -324,7 +657,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 324 | 657 | ||
| 325 | /** Get a user's membership in an organization. */ | 658 | /** Get a user's membership in an organization. */ |
| 326 | getUserOrgMembership: (organizationId: string, userId: string) => | 659 | getUserOrgMembership: (organizationId: string, userId: string) => |
| 327 | use(async (wos) => { | 660 | use("userManagement.listOrganizationMemberships", async (wos) => { |
| 328 | const response = await wos.userManagement.listOrganizationMemberships({ | 661 | const response = await wos.userManagement.listOrganizationMemberships({ |
| 329 | organizationId, | 662 | organizationId, |
| 330 | userId, | 663 | userId, |
| @@ -334,11 +667,23 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 334 | }), | 667 | }), |
| 335 | 668 | ||
| 336 | /** Get a user by ID. */ | 669 | /** Get a user by ID. */ |
| 337 | 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 | ), | ||
| 338 | 683 | ||
| 339 | /** Send an organization invitation. */ | 684 | /** Send an organization invitation. */ |
| 340 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => | 685 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => |
| 341 | use((wos) => | 686 | use("userManagement.sendInvitation", (wos) => |
| 342 | wos.userManagement.sendInvitation({ | 687 | wos.userManagement.sendInvitation({ |
| 343 | email: params.email, | 688 | email: params.email, |
| 344 | organizationId: params.organizationId, | 689 | organizationId: params.organizationId, |
| @@ -352,7 +697,7 @@ const make = Effect.gen(function* () { · 7 unmodified lines | |||
| 352 | * API level, so we filter after. | 697 | * API level, so we filter after. |
| 353 | */ | 698 | */ |
| 354 | listPendingInvitations: (organizationId: string) => | 699 | listPendingInvitations: (organizationId: string) => |
| 355 | use(async (wos) => | 700 | use("userManagement.listInvitations", async (wos) => |
| 356 | collectWorkOSList( | 701 | collectWorkOSList( |
| 357 | await wos.userManagement.listInvitations({ | 702 | await wos.userManagement.listInvitations({ |
| 358 | organizationId, | 703 | organizationId, |
| @@ -367,7 +712,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 367 | 712 | ||
| 368 | /** List invitations for an email address (across all orgs). */ | 713 | /** List invitations for an email address (across all orgs). */ |
| 369 | listInvitationsByEmail: (email: string) => | 714 | listInvitationsByEmail: (email: string) => |
| 370 | use(async (wos) => | 715 | use("userManagement.listInvitations", async (wos) => |
| 371 | collectWorkOSList( | 716 | collectWorkOSList( |
| 372 | await wos.userManagement.listInvitations({ | 717 | await wos.userManagement.listInvitations({ |
| 373 | email, | 718 | email, |
| @@ -377,35 +722,63 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 377 | 722 | ||
| 378 | /** Accept an invitation; returns the (now accepted) invitation. */ | 723 | /** Accept an invitation; returns the (now accepted) invitation. */ |
| 379 | acceptInvitation: (invitationId: string) => | 724 | acceptInvitation: (invitationId: string) => |
| 380 | use((wos) => wos.userManagement.acceptInvitation(invitationId)), | 725 | use("userManagement.acceptInvitation", (wos) => |
| 726 | wos.userManagement.acceptInvitation(invitationId), | ||
| 727 | ), | ||
| 381 | 728 | ||
| 382 | /** Remove an organization membership. */ | 729 | /** Remove an organization membership. */ |
| 383 | deleteOrgMembership: (membershipId: string) => | 730 | deleteOrgMembership: (membershipId: string) => |
| 384 | use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), | 731 | use("userManagement.deleteOrganizationMembership", (wos) => |
| 732 | wos.userManagement.deleteOrganizationMembership(membershipId), | ||
| 733 | ), | ||
| 385 | 734 | ||
| 386 | /** Get the role for a membership. */ | 735 | /** Get the role for a membership. */ |
| 387 | getOrgMembership: (membershipId: string) => | 736 | getOrgMembership: (membershipId: string) => |
| 388 | use((wos) => wos.userManagement.getOrganizationMembership(membershipId)), | 737 | use("userManagement.getOrganizationMembership", (wos) => |
| 738 | wos.userManagement.getOrganizationMembership(membershipId), | ||
| 739 | ), | ||
| 389 | 740 | ||
| 390 | /** Update a membership's role. */ | 741 | /** Update a membership's role. */ |
| 391 | updateOrgMembershipRole: (membershipId: string, roleSlug: string) => | 742 | updateOrgMembershipRole: (membershipId: string, roleSlug: string) => |
| 392 | use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })), | 743 | use("userManagement.updateOrganizationMembership", (wos) => |
| 744 | wos.userManagement.updateOrganizationMembership(membershipId, { | ||
| 745 | roleSlug, | ||
| 746 | }), | ||
| 747 | ), | ||
| 393 | 748 | ||
| 394 | /** List available roles for an organization. */ | 749 | /** List available roles for an organization. */ |
| 395 | listOrgRoles: (organizationId: string) => | 750 | listOrgRoles: (organizationId: string) => |
| 396 | use((wos) => wos.organizations.listOrganizationRoles({ organizationId })), | 751 | use("organizations.listOrganizationRoles", (wos) => |
| 752 | wos.organizations.listOrganizationRoles({ organizationId }), | ||
| 753 | ), | ||
| 397 | 754 | ||
| 398 | /** Get an organization (includes domains). */ | 755 | /** Get an organization (includes domains). */ |
| 399 | getOrganization: (organizationId: string) => | 756 | getOrganization: (organizationId: string) => |
| 400 | use((wos) => wos.organizations.getOrganization(organizationId)), | 757 | use("organizations.getOrganization", (wos) => |
| 758 | wos.organizations.getOrganization(organizationId), | ||
| 759 | ), | ||
| 401 | 760 | ||
| 402 | /** Update an organization. */ | 761 | /** Update an organization. */ |
| 403 | updateOrganization: (organizationId: string, name: string) => | 762 | updateOrganization: (organizationId: string, name: string) => |
| 404 | use((wos) => wos.organizations.updateOrganization({ organization: organizationId, name })), | 763 | use("organizations.updateOrganization", (wos) => |
| 764 | wos.organizations.updateOrganization({ | ||
| 765 | organization: organizationId, | ||
| 766 | name, | ||
| 767 | }), | ||
| 768 | ), | ||
| 769 | |||
| 770 | /** | ||
| 771 | * Delete an organization. Cascades in WorkOS: the org's memberships, | ||
| 772 | * invitations, and domains go with it, so every member loses access. | ||
| 773 | */ | ||
| 774 | deleteOrganization: (organizationId: string) => | ||
| 775 | use("organizations.deleteOrganization", (wos) => | ||
| 776 | wos.organizations.deleteOrganization(organizationId), | ||
| 777 | ), | ||
| 405 | 778 | ||
| 406 | /** Generate an Admin Portal link for domain verification. */ | 779 | /** Generate an Admin Portal link for domain verification. */ |
| 407 | generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => | 780 | generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => |
| 408 | use((wos) => | 781 | use("portal.generateLink", (wos) => |
| 409 | wos.portal.generateLink({ | 782 | wos.portal.generateLink({ |
| 410 | organization: organizationId, | 783 | organization: organizationId, |
| 411 | intent: GeneratePortalLinkIntent.DomainVerification, | 784 | intent: GeneratePortalLinkIntent.DomainVerification, |
| @@ -415,11 +788,11 @@ const make = Effect.gen(function* () { · 3 unmodified lines | |||
| 415 | 788 | ||
| 416 | /** Get a domain by ID. */ | 789 | /** Get a domain by ID. */ |
| 417 | getOrganizationDomain: (domainId: string) => | 790 | getOrganizationDomain: (domainId: string) => |
| 418 | use((wos) => wos.organizationDomains.get(domainId)), | 791 | use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)), |
| 419 | 792 | ||
| 420 | /** Delete a domain claim. */ | 793 | /** Delete a domain claim. */ |
| 421 | deleteOrganizationDomain: (domainId: string) => | 794 | deleteOrganizationDomain: (domainId: string) => |
| 422 | use((wos) => wos.organizationDomains.delete(domainId)), | 795 | use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)), |
| 423 | }; | 796 | }; |
| 424 | }); | 797 | }); |
| 425 | 798 | ||
| @@ -428,9 +801,10 @@ export type WorkOSClientService = Effect.Success<typeof make>; · 2 unmodified lines | |||
| 428 | export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()( | 801 | export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()( |
| 429 | "@executor-js/cloud/WorkOSClient", | 802 | "@executor-js/cloud/WorkOSClient", |
| 430 | ) { | 803 | ) { |
| 431 | static Default = Layer.effect(this)(make).pipe( | 804 | // Deliberately unspanned: client construction is synchronous and ran per |
| 432 | Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), | 805 | // layer build, which produced one of the highest-volume zero-duration span |
| 433 | ); | 806 | // names in the whole trace corpus while telling us nothing. |
| 807 | static Default = Layer.effect(this)(make); | ||
| 434 | } | 808 | } |
| 435 | 809 | ||
| 436 | // The boot-scoped WorkOS client root — the one neutral service the stateless | 810 | // The boot-scoped WorkOS client root — the one neutral service the stateless |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | // merge of cloud + executor schemas. If ANY call site builds its own | 2 | // merge of cloud + executor schemas. If ANY call site builds its own |
| 3 | // `{ schema }` without spreading both, `db._.fullSchema` comes back | 3 | // `{ schema }` without spreading both, `db._.fullSchema` comes back |
| 4 | // missing tables and the drizzle adapter throws | 4 | // missing tables and the drizzle adapter throws |
| 5 | // `unknown model "source"` at the first request that touches scoped data. | 5 | // `unknown model "integration"` at the first request that touches scoped data. |
| 6 | // | 6 | // |
| 7 | // We hit this in prod (MCP endpoint) when `mcp-session.ts` built | 7 | // We hit this in prod (MCP endpoint) when `mcp-session.ts` built |
| 8 | // `combinedSchema = { ...cloudSchema }` and forgot to spread the executor | 8 | // `combinedSchema = { ...cloudSchema }` and forgot to spread the executor |
| @@ -56,7 +56,7 @@ describe("combinedSchema", () => { · 47 unmodified lines | |||
| 56 | } | 56 | } |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | // Executor-schema drives the scope-sharded tables (source/tool/etc). If | 59 | // Executor-schema drives the scope-sharded tables (integration/tool/etc). If |
| 60 | // any of these go missing the drizzle adapter's `getTable` lookup throws. | 60 | // any of these go missing the drizzle adapter's `getTable` lookup throws. |
| 61 | it("includes scope-sharded executor tables", () => { | 61 | it("includes scope-sharded executor tables", () => { |
| 62 | for (const key of Object.keys(executorSchema)) { | 62 | for (const key of Object.keys(executorSchema)) { |
| @@ -24,7 +24,7 @@ import * as executorSchema from "./executor-schema"; · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | // Exported so every drizzle() call in the cloud app shares one schema | 25 | // Exported so every drizzle() call in the cloud app shares one schema |
| 26 | // object. Historically `mcp-session.ts` built its own and forgot to spread | 26 | // object. Historically `mcp-session.ts` built its own and forgot to spread |
| 27 | // `executorSchema`, producing runtime "unknown model source" errors that | 27 | // `executorSchema`, producing runtime "unknown model integration" errors that |
| 28 | // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. | 28 | // only surfaced in prod. See apps/cloud/src/db/db.schema.test.ts. |
| 29 | export const combinedSchema = { ...cloudSchema, ...executorSchema }; | 29 | export const combinedSchema = { ...cloudSchema, ...executorSchema }; |
| 30 | 30 | ||
| @@ -21,6 +21,8 @@ · 20 unmodified lines | |||
| 21 | // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through | 21 | // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through |
| 22 | // one PGLiteSocketServer and asserts zero protocol corruption. | 22 | // one PGLiteSocketServer and asserts zero protocol corruption. |
| 23 | 23 | ||
| 24 | import { setTimeout as sleep } from "node:timers/promises"; | ||
| 25 | import { connect, type Socket } from "node:net"; | ||
| 24 | import { describe, expect, it } from "@effect/vitest"; | 26 | import { describe, expect, it } from "@effect/vitest"; |
| 25 | import { PGlite } from "@electric-sql/pglite"; | 27 | import { PGlite } from "@electric-sql/pglite"; |
| 26 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; | 28 | import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; |
| @@ -30,6 +32,50 @@ const PORT = 45998; · 3 unmodified lines | |||
| 30 | const CLIENTS = 6; | 32 | const CLIENTS = 6; |
| 31 | const QUERIES_PER_CLIENT = 40; | 33 | const QUERIES_PER_CLIENT = 40; |
| 32 | 34 | ||
| 35 | const makeClient = (port: number, connectTimeout = 5) => | ||
| 36 | postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { | ||
| 37 | max: 1, | ||
| 38 | idle_timeout: 0, | ||
| 39 | connect_timeout: connectTimeout, | ||
| 40 | fetch_types: false, | ||
| 41 | prepare: true, | ||
| 42 | onnotice: () => undefined, | ||
| 43 | }); | ||
| 44 | |||
| 45 | // Hand-rolled wire client: connect and complete the trust-auth startup, so a | ||
| 46 | // test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js | ||
| 47 | // would never emit on its own. Resolves after ReadyForQuery so the next write | ||
| 48 | // is its own data event — and its own queue entry — on the server. | ||
| 49 | const openWireClient = async (port: number): Promise<Socket> => { | ||
| 50 | const socket: Socket = connect(port, "127.0.0.1"); | ||
| 51 | await new Promise<void>((res, rej) => { | ||
| 52 | socket.once("connect", res); | ||
| 53 | socket.once("error", rej); | ||
| 54 | }); | ||
| 55 | const startupBody = Buffer.concat([ | ||
| 56 | Buffer.from([0, 3, 0, 0]), | ||
| 57 | Buffer.from("user\0postgres\0database\0postgres\0\0"), | ||
| 58 | ]); | ||
| 59 | const startup = Buffer.concat([Buffer.alloc(4), startupBody]); | ||
| 60 | startup.writeInt32BE(startup.length, 0); | ||
| 61 | socket.write(startup); | ||
| 62 | await new Promise<void>((res) => { | ||
| 63 | socket.on("data", (chunk: Buffer) => { | ||
| 64 | if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | return socket; | ||
| 68 | }; | ||
| 69 | |||
| 70 | // A Parse frame for an unnamed statement: opens an extended-protocol pipeline | ||
| 71 | // that only a later Sync (or the server's recovery) closes. | ||
| 72 | const parseFrame = (query: string): Buffer => { | ||
| 73 | const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]); | ||
| 74 | const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]); | ||
| 75 | frame.writeInt32BE(4 + body.length, 1); | ||
| 76 | return frame; | ||
| 77 | }; | ||
| 78 | |||
| 33 | describe("dev-db PGlite socket under concurrent connections", () => { | 79 | describe("dev-db PGlite socket under concurrent connections", () => { |
| 34 | it( | 80 | it( |
| 35 | "serves interleaved multi-connection pipelines without protocol corruption", | 81 | "serves interleaved multi-connection pipelines without protocol corruption", |
| @@ -48,14 +94,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 12 unmodified lines | |||
| 48 | const errors: string[] = []; | 94 | const errors: string[] = []; |
| 49 | 95 | ||
| 50 | const worker = async (id: number) => { | 96 | const worker = async (id: number) => { |
| 51 | const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { | 97 | const sql = makeClient(PORT, 10); |
| 52 | max: 1, | ||
| 53 | idle_timeout: 0, | ||
| 54 | connect_timeout: 10, | ||
| 55 | fetch_types: false, | ||
| 56 | prepare: true, | ||
| 57 | onnotice: () => undefined, | ||
| 58 | }); | ||
| 59 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path | 98 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path |
| 60 | try { | 99 | try { |
| 61 | for (let q = 0; q < QUERIES_PER_CLIENT; q++) { | 100 | for (let q = 0; q < QUERIES_PER_CLIENT; q++) { |
| @@ -91,4 +130,229 @@ describe("dev-db PGlite socket under concurrent connections", () => { · 29 unmodified lines | |||
| 91 | expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); | 130 | expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); |
| 92 | }, | 131 | }, |
| 93 | ); | 132 | ); |
| 133 | |||
| 134 | // Regression for the CI e2e "cloud signIn: callback set no session (500)" | ||
| 135 | // cascade: QueryQueueManager.processQueue used to `return` out of its drain | ||
| 136 | // loop when a query REJECTED (as opposed to returning a wire-level | ||
| 137 | // ErrorResponse), leaving `processing` latched true. From then on every | ||
| 138 | // enqueue — including brand-new connections' startup packets — sat in the | ||
| 139 | // queue forever: in-flight requests hung, postgres.js reconnects died with | ||
| 140 | // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The | ||
| 141 | // patch rejects the one entry, drops pipeline affinity, and keeps draining. | ||
| 142 | it( | ||
| 143 | "a rejected query fails one client, not the whole socket server", | ||
| 144 | { timeout: 30_000 }, | ||
| 145 | async () => { | ||
| 146 | const port = 45997; | ||
| 147 | const db = await PGlite.create(); | ||
| 148 | const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); | ||
| 149 | await server.start(); | ||
| 150 | |||
| 151 | const first = makeClient(port); | ||
| 152 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 153 | try { | ||
| 154 | expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); | ||
| 155 | |||
| 156 | // Force the NEXT protocol exchange to reject at the JS level, the shape | ||
| 157 | // PGlite produces when the shared session is broken mid-run. | ||
| 158 | const real = db.execProtocolRawStream.bind(db); | ||
| 159 | let arm = true; | ||
| 160 | (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => { | ||
| 161 | if (arm) { | ||
| 162 | arm = false; | ||
| 163 | // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw | ||
| 164 | throw new Error("synthetic PGlite failure"); | ||
| 165 | } | ||
| 166 | return real(...args); | ||
| 167 | }; | ||
| 168 | |||
| 169 | await expect(first.unsafe(`select 2 as two`)).rejects.toThrow(); | ||
| 170 | |||
| 171 | // The poisoned entry must take down only its own connection: a fresh | ||
| 172 | // client (new socket, full startup handshake) still gets served. | ||
| 173 | const second = makeClient(port); | ||
| 174 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 175 | try { | ||
| 176 | expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 }); | ||
| 177 | } finally { | ||
| 178 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 179 | await second.end({ timeout: 5 }).catch(() => {}); | ||
| 180 | } | ||
| 181 | } finally { | ||
| 182 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 183 | await first.end({ timeout: 5 }).catch(() => {}); | ||
| 184 | await server.stop(); | ||
| 185 | await db.close(); | ||
| 186 | } | ||
| 187 | }, | ||
| 188 | ); | ||
| 189 | |||
| 190 | // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's | ||
| 191 | // idleTimeout backstop used to kill ANY connection with no traffic for the | ||
| 192 | // window, which is the resting state of every healthy postgres.js pool | ||
| 193 | // connection (idle_timeout: 0) held by a long-lived scope. The backstop now | ||
| 194 | // only fires on a connection that is actually blocking the shared session — | ||
| 195 | // an open pipeline or an open transaction. | ||
| 196 | it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => { | ||
| 197 | const port = 45996; | ||
| 198 | const db = await PGlite.create(); | ||
| 199 | const server = new PGLiteSocketServer({ | ||
| 200 | db, | ||
| 201 | port, | ||
| 202 | host: "127.0.0.1", | ||
| 203 | maxConnections: 100, | ||
| 204 | idleTimeout: 250, | ||
| 205 | }); | ||
| 206 | await server.start(); | ||
| 207 | |||
| 208 | const sql = makeClient(port); | ||
| 209 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 210 | try { | ||
| 211 | expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); | ||
| 212 | await sleep(900); | ||
| 213 | expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 }); | ||
| 214 | } finally { | ||
| 215 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 216 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 217 | await server.stop(); | ||
| 218 | await db.close(); | ||
| 219 | } | ||
| 220 | }); | ||
| 221 | |||
| 222 | // The backstop's actual job still works: a client that opens a pipeline | ||
| 223 | // (Parse sent, never Sync) and goes silent holds queue affinity, which | ||
| 224 | // starves every other connection. The idle timer must reap exactly that | ||
| 225 | // client and hand the queue back. | ||
| 226 | it( | ||
| 227 | "a client stalled mid-pipeline is reaped and the queue recovers", | ||
| 228 | { timeout: 30_000 }, | ||
| 229 | async () => { | ||
| 230 | const port = 45995; | ||
| 231 | const db = await PGlite.create(); | ||
| 232 | const server = new PGLiteSocketServer({ | ||
| 233 | db, | ||
| 234 | port, | ||
| 235 | host: "127.0.0.1", | ||
| 236 | maxConnections: 100, | ||
| 237 | idleTimeout: 250, | ||
| 238 | }); | ||
| 239 | await server.start(); | ||
| 240 | |||
| 241 | // Hand-rolled wire client: complete the trust-auth startup, then send a | ||
| 242 | // lone Parse. Its last frame type ('P') marks the pipeline open, so the | ||
| 243 | // handler takes affinity and every other connection queues behind it. | ||
| 244 | const staller = await openWireClient(port); | ||
| 245 | staller.write(parseFrame("select 1")); | ||
| 246 | |||
| 247 | const bystander = makeClient(port, 10); | ||
| 248 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 249 | try { | ||
| 250 | // Connects and queries only once the staller is reaped (~250ms). | ||
| 251 | expect((await bystander.unsafe(`select 4 as four`))[0]).toEqual({ four: 4 }); | ||
| 252 | } finally { | ||
| 253 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 254 | await bystander.end({ timeout: 5 }).catch(() => {}); | ||
| 255 | staller.destroy(); | ||
| 256 | await server.stop(); | ||
| 257 | await db.close(); | ||
| 258 | } | ||
| 259 | }, | ||
| 260 | ); | ||
| 261 | |||
| 262 | // Regression for the reap SLOT LEAK: detach(true) removes the socket's | ||
| 263 | // listeners before destroying it, so a server-initiated teardown (the idle | ||
| 264 | // backstop) never fired the server's 'close' bookkeeping — the reaped | ||
| 265 | // handler stayed in the server's handlers set forever, burning one | ||
| 266 | // maxConnections slot per reap. Enough reaps over a long run and the server | ||
| 267 | // answers every NEW connection with "Too many connections" while the | ||
| 268 | // process, the port, and PGlite are all healthy — postgres.js surfaces that | ||
| 269 | // as the same CONNECT_TIMEOUT cascade as the queue wedges. The server now | ||
| 270 | // drops the handler when it dispatches its terminal error. | ||
| 271 | it("reaped handlers release their connection slots", { timeout: 30_000 }, async () => { | ||
| 272 | const port = 45993; | ||
| 273 | const db = await PGlite.create(); | ||
| 274 | const server = new PGLiteSocketServer({ | ||
| 275 | db, | ||
| 276 | port, | ||
| 277 | host: "127.0.0.1", | ||
| 278 | maxConnections: 2, | ||
| 279 | idleTimeout: 250, | ||
| 280 | }); | ||
| 281 | await server.start(); | ||
| 282 | |||
| 283 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 284 | try { | ||
| 285 | // Burn through more reaps than there are slots: each staller opens a | ||
| 286 | // pipeline and goes silent, so the idle backstop reaps it (the server | ||
| 287 | // destroys the socket — its 'close' marks that reap complete). | ||
| 288 | for (let i = 0; i < 3; i++) { | ||
| 289 | const staller = await openWireClient(port); | ||
| 290 | staller.write(parseFrame(`select ${i + 1}`)); | ||
| 291 | await new Promise<void>((res) => staller.once("close", res)); | ||
| 292 | } | ||
| 293 | |||
| 294 | expect( | ||
| 295 | server.getStats().activeConnections, | ||
| 296 | "reaped handlers stay counted against maxConnections", | ||
| 297 | ).toBe(0); | ||
| 298 | |||
| 299 | const sql = makeClient(port); | ||
| 300 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 301 | try { | ||
| 302 | expect((await sql.unsafe(`select 6 as six`))[0]).toEqual({ six: 6 }); | ||
| 303 | } finally { | ||
| 304 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 305 | await sql.end({ timeout: 5 }).catch(() => {}); | ||
| 306 | } | ||
| 307 | } finally { | ||
| 308 | await server.stop(); | ||
| 309 | await db.close(); | ||
| 310 | } | ||
| 311 | }); | ||
| 312 | |||
| 313 | // Regression for the second wedge mode behind the same CI cascade: a client | ||
| 314 | // whose socket dies WHILE its pipeline-opening entry is executing. detach() | ||
| 315 | // clears pipeline affinity before the entry finishes, so the queue then | ||
| 316 | // assigned affinity to the already-dead handler — and nothing ever cleared | ||
| 317 | // it: the dead handler has no timers left, and every other connection | ||
| 318 | // (including fresh startups) queued behind the ghost forever. The queue now | ||
| 319 | // tracks detached handlers and repairs affinity they can no longer release. | ||
| 320 | it( | ||
| 321 | "a client that dies mid-execution does not leave the queue pinned to its ghost", | ||
| 322 | { timeout: 30_000 }, | ||
| 323 | async () => { | ||
| 324 | const port = 45994; | ||
| 325 | const db = await PGlite.create(); | ||
| 326 | |||
| 327 | // Hold the marker query in flight long enough that the disconnect below | ||
| 328 | // reliably lands while the entry is EXECUTING (after detach's cleanup, | ||
| 329 | // before the queue takes affinity for it). | ||
| 330 | const real = db.execProtocolRawStream.bind(db); | ||
| 331 | (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => { | ||
| 332 | if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300); | ||
| 333 | return real(...args); | ||
| 334 | }; | ||
| 335 | |||
| 336 | const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); | ||
| 337 | await server.start(); | ||
| 338 | |||
| 339 | const ghost = await openWireClient(port); | ||
| 340 | ghost.write(parseFrame("select 'ghost_marker'")); | ||
| 341 | // Give the data event time to reach the queue and start executing, then | ||
| 342 | // die without a trace mid-flight. | ||
| 343 | await sleep(100); | ||
| 344 | ghost.destroy(); | ||
| 345 | |||
| 346 | const bystander = makeClient(port); | ||
| 347 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path | ||
| 348 | try { | ||
| 349 | expect((await bystander.unsafe(`select 5 as five`))[0]).toEqual({ five: 5 }); | ||
| 350 | } finally { | ||
| 351 | // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion | ||
| 352 | await bystander.end({ timeout: 5 }).catch(() => {}); | ||
| 353 | await server.stop(); | ||
| 354 | await db.close(); | ||
| 355 | } | ||
| 356 | }, | ||
| 357 | ); | ||
| 94 | }); | 358 | }); |
| @@ -33,6 +33,22 @@ export const integration = pgTable( · 32 unmodified lines | |||
| 33 | (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], | 33 | (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], |
| 34 | ); | 34 | ); |
| 35 | 35 | ||
| 36 | export const subject = pgTable( | ||
| 37 | "subject", | ||
| 38 | { | ||
| 39 | external_id: varchar("external_id", { length: 255 }).notNull(), | ||
| 40 | created_at: timestamp("created_at").notNull(), | ||
| 41 | last_seen_at: bigint("last_seen_at", { mode: "bigint" }), | ||
| 42 | status: text("status"), | ||
| 43 | row_id: varchar("row_id", { length: 255 }) | ||
| 44 | .primaryKey() | ||
| 45 | .notNull() | ||
| 46 | .$defaultFn(() => createId()), | ||
| 47 | tenant: varchar("tenant", { length: 255 }).notNull(), | ||
| 48 | }, | ||
| 49 | (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], | ||
| 50 | ); | ||
| 51 | |||
| 36 | export const connection = pgTable( | 52 | export const connection = pgTable( |
| 37 | "connection", | 53 | "connection", |
| 38 | { | 54 | { |
| @@ -86,6 +102,7 @@ export const oauth_client = pgTable( · 47 unmodified lines | |||
| 86 | origin_kind: text("origin_kind"), | 102 | origin_kind: text("origin_kind"), |
| 87 | origin_integration: text("origin_integration"), | 103 | origin_integration: text("origin_integration"), |
| 88 | origin_issuer: text("origin_issuer"), | 104 | origin_issuer: text("origin_issuer"), |
| 105 | origin_redirect_uri: text("origin_redirect_uri"), | ||
| 89 | created_at: timestamp("created_at").notNull(), | 106 | created_at: timestamp("created_at").notNull(), |
| 90 | row_id: varchar("row_id", { length: 255 }) | 107 | row_id: varchar("row_id", { length: 255 }) |
| 91 | .primaryKey() | 108 | .primaryKey() |
| @@ -164,7 +181,7 @@ export const definition = pgTable( · 72 unmodified lines | |||
| 164 | integration: varchar("integration", { length: 255 }).notNull(), | 181 | integration: varchar("integration", { length: 255 }).notNull(), |
| 165 | connection: varchar("connection", { length: 255 }).notNull(), | 182 | connection: varchar("connection", { length: 255 }).notNull(), |
| 166 | plugin_id: text("plugin_id").notNull(), | 183 | plugin_id: text("plugin_id").notNull(), |
| 167 | name: varchar("name", { length: 255 }).notNull(), | 184 | name: text("name").notNull(), |
| 168 | schema: json("schema").notNull(), | 185 | schema: json("schema").notNull(), |
| 169 | created_at: timestamp("created_at").notNull(), | 186 | created_at: timestamp("created_at").notNull(), |
| 170 | row_id: varchar("row_id", { length: 255 }) | 187 | row_id: varchar("row_id", { length: 255 }) |
| @@ -209,6 +226,28 @@ export const tool_policy = pgTable( · 38 unmodified lines | |||
| 209 | ], | 226 | ], |
| 210 | ); | 227 | ); |
| 211 | 228 | ||
| 229 | export const artifact = pgTable( | ||
| 230 | "artifact", | ||
| 231 | { | ||
| 232 | id: varchar("id", { length: 255 }).notNull(), | ||
| 233 | title: text("title").notNull(), | ||
| 234 | description: text("description"), | ||
| 235 | code: text("code").notNull(), | ||
| 236 | bindings: json("bindings"), | ||
| 237 | preview: text("preview"), | ||
| 238 | created_at: timestamp("created_at").notNull(), | ||
| 239 | updated_at: timestamp("updated_at").notNull(), | ||
| 240 | row_id: varchar("row_id", { length: 255 }) | ||
| 241 | .primaryKey() | ||
| 242 | .notNull() | ||
| 243 | .$defaultFn(() => createId()), | ||
| 244 | tenant: varchar("tenant", { length: 255 }).notNull(), | ||
| 245 | owner: varchar("owner", { length: 255 }).notNull(), | ||
| 246 | subject: varchar("subject", { length: 255 }).notNull(), | ||
| 247 | }, | ||
| 248 | (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], | ||
| 249 | ); | ||
| 250 | |||
| 212 | export const plugin_storage = pgTable( | 251 | export const plugin_storage = pgTable( |
| 213 | "plugin_storage", | 252 | "plugin_storage", |
| 214 | { | 253 | { |
| @@ -0,0 +1,363 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // purgeOrganizationData — org deletion cascade | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // Runs inside the Cloudflare Workers runtime against a real PGlite Postgres | ||
| 6 | // (scripts/test-globalsetup.ts), the same path api.ts uses per request. Seeds | ||
| 7 | // two orgs across every tenant table + blob namespace, purges one, and asserts: | ||
| 8 | // - every executor tenant table row for the target org is gone | ||
| 9 | // - org- and user-scoped secret blobs for the target org are gone | ||
| 10 | // - the identity row is gone and its memberships cascade with it | ||
| 11 | // - a second org's data is completely untouched | ||
| 12 | // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must | ||
| 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. | ||
| 19 | |||
| 20 | import { describe, it, expect } from "@effect/vitest"; | ||
| 21 | import { Effect } from "effect"; | ||
| 22 | import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm"; | ||
| 23 | |||
| 24 | import { DbService } from "./db"; | ||
| 25 | import type { DrizzleDb } from "./db"; | ||
| 26 | import { makeUserStore } from "../auth/user-store"; | ||
| 27 | import * as cloudSchema from "./schema"; | ||
| 28 | import * as executorSchema from "./executor-schema"; | ||
| 29 | import { memberships, accounts } from "./schema"; | ||
| 30 | import { | ||
| 31 | artifact, | ||
| 32 | blob, | ||
| 33 | connection, | ||
| 34 | definition, | ||
| 35 | integration, | ||
| 36 | oauth_client, | ||
| 37 | oauth_session, | ||
| 38 | plugin_storage, | ||
| 39 | subject, | ||
| 40 | tool, | ||
| 41 | tool_policy, | ||
| 42 | } from "./executor-schema"; | ||
| 43 | |||
| 44 | const program = <A, E>(body: Effect.Effect<A, E, DbService>) => | ||
| 45 | Effect.runPromise( | ||
| 46 | body.pipe(Effect.provide(DbService.Live), Effect.scoped) as Effect.Effect<A, E, never>, | ||
| 47 | ); | ||
| 48 | |||
| 49 | // Insert one row into every tenant table, plus an org- and a user-scoped blob, | ||
| 50 | // all owned by `tenant`. `tag` keeps unique indexes from colliding across orgs. | ||
| 51 | const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { | ||
| 52 | const now = new Date(); | ||
| 53 | await db.insert(integration).values({ | ||
| 54 | slug: `int-${tag}`, | ||
| 55 | plugin_id: "p", | ||
| 56 | created_at: now, | ||
| 57 | updated_at: now, | ||
| 58 | tenant, | ||
| 59 | }); | ||
| 60 | await db.insert(connection).values({ | ||
| 61 | integration: "int", | ||
| 62 | name: `conn-${tag}`, | ||
| 63 | template: "t", | ||
| 64 | provider: "pr", | ||
| 65 | item_ids: [], | ||
| 66 | created_at: now, | ||
| 67 | updated_at: now, | ||
| 68 | tenant, | ||
| 69 | owner: "o", | ||
| 70 | subject: "s", | ||
| 71 | }); | ||
| 72 | await db.insert(oauth_client).values({ | ||
| 73 | slug: `oc-${tag}`, | ||
| 74 | authorization_url: "u", | ||
| 75 | token_url: "u", | ||
| 76 | grant: "g", | ||
| 77 | client_id: "c", | ||
| 78 | created_at: now, | ||
| 79 | tenant, | ||
| 80 | owner: "o", | ||
| 81 | subject: "s", | ||
| 82 | }); | ||
| 83 | await db.insert(oauth_session).values({ | ||
| 84 | state: `state-${tag}`, | ||
| 85 | client_slug: "cs", | ||
| 86 | integration: "int", | ||
| 87 | name: "n", | ||
| 88 | template: "t", | ||
| 89 | redirect_url: "r", | ||
| 90 | payload: {}, | ||
| 91 | expires_at: 0n, | ||
| 92 | created_at: now, | ||
| 93 | tenant, | ||
| 94 | owner: "o", | ||
| 95 | subject: "s", | ||
| 96 | }); | ||
| 97 | await db.insert(tool).values({ | ||
| 98 | integration: "int", | ||
| 99 | connection: "conn", | ||
| 100 | plugin_id: "p", | ||
| 101 | name: `tool-${tag}`, | ||
| 102 | description: "d", | ||
| 103 | created_at: now, | ||
| 104 | updated_at: now, | ||
| 105 | tenant, | ||
| 106 | owner: "o", | ||
| 107 | subject: "s", | ||
| 108 | }); | ||
| 109 | await db.insert(definition).values({ | ||
| 110 | integration: "int", | ||
| 111 | connection: "conn", | ||
| 112 | plugin_id: "p", | ||
| 113 | name: `def-${tag}`, | ||
| 114 | schema: {}, | ||
| 115 | created_at: now, | ||
| 116 | tenant, | ||
| 117 | owner: "o", | ||
| 118 | subject: "s", | ||
| 119 | }); | ||
| 120 | await db.insert(tool_policy).values({ | ||
| 121 | id: `tp-${tag}`, | ||
| 122 | pattern: "*", | ||
| 123 | action: "allow", | ||
| 124 | position: "1", | ||
| 125 | created_at: now, | ||
| 126 | updated_at: now, | ||
| 127 | tenant, | ||
| 128 | owner: "o", | ||
| 129 | subject: "s", | ||
| 130 | }); | ||
| 131 | await db.insert(plugin_storage).values({ | ||
| 132 | plugin_id: "p", | ||
| 133 | collection: "col", | ||
| 134 | key: `k-${tag}`, | ||
| 135 | data: {}, | ||
| 136 | created_at: now, | ||
| 137 | updated_at: now, | ||
| 138 | tenant, | ||
| 139 | owner: "o", | ||
| 140 | subject: "s", | ||
| 141 | }); | ||
| 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 | |||
| 161 | const orgNs = `o:${tenant}/plugin`; | ||
| 162 | const userNs = `u:${tenant}:subject/plugin`; | ||
| 163 | await db.insert(blob).values({ | ||
| 164 | namespace: orgNs, | ||
| 165 | key: "k", | ||
| 166 | value: "v", | ||
| 167 | id: JSON.stringify([orgNs, "k"]), | ||
| 168 | }); | ||
| 169 | await db.insert(blob).values({ | ||
| 170 | namespace: userNs, | ||
| 171 | key: "k", | ||
| 172 | value: "v", | ||
| 173 | id: JSON.stringify([userNs, "k"]), | ||
| 174 | }); | ||
| 175 | }; | ||
| 176 | |||
| 177 | const TENANT_TABLES = [ | ||
| 178 | integration, | ||
| 179 | connection, | ||
| 180 | oauth_client, | ||
| 181 | oauth_session, | ||
| 182 | tool, | ||
| 183 | definition, | ||
| 184 | tool_policy, | ||
| 185 | plugin_storage, | ||
| 186 | subject, | ||
| 187 | artifact, | ||
| 188 | ] as const; | ||
| 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 | |||
| 206 | const countTenantRows = async (db: DrizzleDb, tenant: string): Promise<number> => { | ||
| 207 | let total = 0; | ||
| 208 | for (const table of TENANT_TABLES) { | ||
| 209 | const rows = await db.select().from(table).where(eq(table.tenant, tenant)); | ||
| 210 | total += rows.length; | ||
| 211 | } | ||
| 212 | // Count by the exact namespaces `seedTenant` inserts. Matching on a `%tenant%` | ||
| 213 | // LIKE here would reintroduce the very wildcard hazard under test. | ||
| 214 | const blobs = await db | ||
| 215 | .select() | ||
| 216 | .from(blob) | ||
| 217 | .where(inArray(blob.namespace, [`o:${tenant}/plugin`, `u:${tenant}:subject/plugin`])); | ||
| 218 | return total + blobs.length; | ||
| 219 | }; | ||
| 220 | |||
| 221 | describe("purgeOrganizationData", () => { | ||
| 222 | it("removes all of one org's data and leaves other orgs untouched", async () => { | ||
| 223 | // Underscore in the id is deliberate: an unescaped `_` in a LIKE pattern is | ||
| 224 | // a single-char wildcard, so the escaping has to hold for this to be safe. | ||
| 225 | const orgA = `org_del_${crypto.randomUUID().slice(0, 8)}`; | ||
| 226 | const orgB = `org_keep_${crypto.randomUUID().slice(0, 8)}`; | ||
| 227 | const accountId = `user_${crypto.randomUUID().slice(0, 8)}`; | ||
| 228 | |||
| 229 | // A look-alike blob that only an UNescaped `_` wildcard would match: | ||
| 230 | // `o:<orgA>/…` with the underscore replaced by another char. | ||
| 231 | const trapNs = `o:${orgA.replace("_", "X")}/plugin`; | ||
| 232 | |||
| 233 | await program( | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const { db } = yield* DbService; | ||
| 236 | yield* Effect.promise(async () => { | ||
| 237 | const store = makeUserStore(db); | ||
| 238 | await store.upsertOrganization({ id: orgA, name: "Delete Me" }); | ||
| 239 | await store.upsertOrganization({ id: orgB, name: "Keep Me" }); | ||
| 240 | await store.ensureAccount(accountId); | ||
| 241 | await db.insert(memberships).values({ accountId, organizationId: orgA }); | ||
| 242 | await db.insert(memberships).values({ accountId, organizationId: orgB }); | ||
| 243 | await seedTenant(db, orgA, "a"); | ||
| 244 | await seedTenant(db, orgB, "b"); | ||
| 245 | await db.insert(blob).values({ | ||
| 246 | namespace: trapNs, | ||
| 247 | key: "k", | ||
| 248 | value: "v", | ||
| 249 | id: JSON.stringify([trapNs, "k"]), | ||
| 250 | }); | ||
| 251 | }); | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | await program( | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const { db } = yield* DbService; | ||
| 258 | yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(orgA)); | ||
| 259 | }), | ||
| 260 | ); | ||
| 261 | |||
| 262 | await program( | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const { db } = yield* DbService; | ||
| 265 | yield* Effect.promise(async () => { | ||
| 266 | const store = makeUserStore(db); | ||
| 267 | |||
| 268 | // Target org: every tenant row + blob gone, identity gone, membership | ||
| 269 | // cascaded, but the shared account survives (it may join other orgs). | ||
| 270 | expect(await countTenantRows(db, orgA)).toBe(0); | ||
| 271 | expect(await store.getOrganization(orgA)).toBeNull(); | ||
| 272 | const orgAMemberships = await db | ||
| 273 | .select() | ||
| 274 | .from(memberships) | ||
| 275 | .where(eq(memberships.organizationId, orgA)); | ||
| 276 | expect(orgAMemberships).toHaveLength(0); | ||
| 277 | const account = await db.select().from(accounts).where(eq(accounts.id, accountId)); | ||
| 278 | expect(account, "the account outlives the org").toHaveLength(1); | ||
| 279 | |||
| 280 | // The look-alike blob must survive: escaping keeps `_` literal. | ||
| 281 | const trap = await db.select().from(blob).where(eq(blob.namespace, trapNs)); | ||
| 282 | expect(trap, "escaped LIKE must not match a look-alike namespace").toHaveLength(1); | ||
| 283 | |||
| 284 | // Second org: fully intact. | ||
| 285 | expect(await countTenantRows(db, orgB)).toBeGreaterThan(0); | ||
| 286 | expect(await store.getOrganization(orgB)).not.toBeNull(); | ||
| 287 | const orgBMemberships = await db | ||
| 288 | .select() | ||
| 289 | .from(memberships) | ||
| 290 | .where(eq(memberships.organizationId, orgB)); | ||
| 291 | expect(orgBMemberships).toHaveLength(1); | ||
| 292 | }); | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | }, 20_000); | ||
| 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 | }); | ||
| @@ -0,0 +1,72 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Organization data purge — removes every local trace of an org in one txn | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // Identity (accounts/organizations/memberships) and executor tenant data | ||
| 6 | // (integrations, connections, tools, secrets, ...) share ONE Postgres database | ||
| 7 | // (`combinedSchema` in `db.ts`), so deleting an org is a single transaction | ||
| 8 | // here rather than a fan-out across stores. Tenant rows carry the org id in a | ||
| 9 | // `tenant` column; secrets/tokens live in `blob`, namespaced by owner. | ||
| 10 | // | ||
| 11 | // External side effects (the WorkOS org, the Autumn customer) are NOT touched | ||
| 12 | // here — the caller (auth handler) sequences those around this purge. | ||
| 13 | |||
| 14 | import { eq, or, sql } from "drizzle-orm"; | ||
| 15 | |||
| 16 | import type { DrizzleDb } from "./db"; | ||
| 17 | import { organizations } from "./schema"; | ||
| 18 | import { | ||
| 19 | artifact, | ||
| 20 | blob, | ||
| 21 | connection, | ||
| 22 | definition, | ||
| 23 | integration, | ||
| 24 | oauth_client, | ||
| 25 | oauth_session, | ||
| 26 | plugin_storage, | ||
| 27 | subject, | ||
| 28 | tool, | ||
| 29 | tool_policy, | ||
| 30 | } from "./executor-schema"; | ||
| 31 | |||
| 32 | // Escape LIKE wildcards (`\`, `%`, `_`) in the org id before using it as a | ||
| 33 | // prefix. WorkOS org ids contain underscores, and a bare `_` in a LIKE pattern | ||
| 34 | // matches any character, which would widen the match to unrelated tenants. | ||
| 35 | const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&"); | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Delete all rows owned by `organizationId`: every executor tenant table, the | ||
| 39 | * org's secret blobs (org- and user-scoped), and the identity mirror row (which | ||
| 40 | * cascades to local `memberships`). Idempotent — a second run deletes nothing. | ||
| 41 | */ | ||
| 42 | export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Promise<void> => | ||
| 43 | db.transaction(async (tx) => { | ||
| 44 | // Executor tenant tables — every row is scoped by `tenant = organizationId`. | ||
| 45 | await tx.delete(tool).where(eq(tool.tenant, organizationId)); | ||
| 46 | await tx.delete(definition).where(eq(definition.tenant, organizationId)); | ||
| 47 | await tx.delete(connection).where(eq(connection.tenant, organizationId)); | ||
| 48 | await tx.delete(integration).where(eq(integration.tenant, organizationId)); | ||
| 49 | await tx.delete(oauth_client).where(eq(oauth_client.tenant, organizationId)); | ||
| 50 | await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); | ||
| 51 | await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); | ||
| 52 | await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); | ||
| 53 | await tx.delete(subject).where(eq(subject.tenant, organizationId)); | ||
| 54 | await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); | ||
| 55 | |||
| 56 | // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by | ||
| 57 | // owner: `o:<org>/<plugin>` (org scope) and `u:<org>:<subject>/<plugin>` | ||
| 58 | // (per-user scope). Match both prefixes for this org. | ||
| 59 | const esc = escapeLike(organizationId); | ||
| 60 | await tx | ||
| 61 | .delete(blob) | ||
| 62 | .where( | ||
| 63 | or( | ||
| 64 | sql`${blob.namespace} LIKE ${`o:${esc}/%`} ESCAPE '\\'`, | ||
| 65 | sql`${blob.namespace} LIKE ${`u:${esc}:%`} ESCAPE '\\'`, | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 69 | // Identity mirror — FK `ON DELETE CASCADE` removes local memberships too. | ||
| 70 | // `accounts` are intentionally left: a user may belong to other orgs. | ||
| 71 | await tx.delete(organizations).where(eq(organizations.id, organizationId)); | ||
| 72 | }); | ||
| @@ -6,43 +6,23 @@ · 5 unmodified lines | |||
| 6 | // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap | 6 | // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap |
| 7 | // to the upstream origin (unlike the PostHog proxy, which strips its prefix). | 7 | // to the upstream origin (unlike the PostHog proxy, which strips its prefix). |
| 8 | // | 8 | // |
| 9 | // Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs | 9 | // The matching, upstream construction, and client span live in `./passthrough`, |
| 10 | // the prod-only `env.MARKETING` service binding), this is a plain external | 10 | // which server.ts dispatches BEFORE Start loads: forwarding a docs page must |
| 11 | // `fetch`, so it runs on every host — `/docs` previews against live Mintlify in | 11 | // not pay for the whole server graph. This middleware stays registered so hosts |
| 12 | // local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), | 12 | // that reach Start by another entry (local dev) keep identical behavior; in the |
| 13 | // so this never shadows an Effect-served route. | 13 | // deployed Worker it is unreachable. `/docs` is distinct from the app-owned |
| 14 | // `/api/docs` (Swagger), so this never shadows an Effect-served route. | ||
| 14 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 15 | 16 | ||
| 16 | import { createMiddleware } from "@tanstack/react-start"; | 17 | import { createMiddleware } from "@tanstack/react-start"; |
| 17 | 18 | ||
| 18 | const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; | 19 | import { docsProxyResponse, isDocsPath } from "./passthrough"; |
| 19 | 20 | ||
| 20 | export const isDocsPath = (pathname: string) => | 21 | export { buildDocsUpstream, isDocsPath } from "./passthrough"; |
| 21 | pathname === "/docs" || pathname.startsWith("/docs/"); | ||
| 22 | |||
| 23 | // Build the upstream request for an already-classified `/docs` path. Caller | ||
| 24 | // guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the | ||
| 25 | // forwarding headers, preserving method, body, path, and query. | ||
| 26 | export const buildDocsUpstream = (request: Request): Request => { | ||
| 27 | const url = new URL(request.url); | ||
| 28 | const forwardedHost = url.host; | ||
| 29 | |||
| 30 | url.hostname = DOCS_UPSTREAM_HOST; | ||
| 31 | url.protocol = "https:"; | ||
| 32 | url.port = ""; | ||
| 33 | |||
| 34 | const upstream = new Request(url, request); | ||
| 35 | // Mintlify keys canonical links off the public host; tell it the real one. | ||
| 36 | upstream.headers.set("X-Forwarded-Host", forwardedHost); | ||
| 37 | upstream.headers.set("X-Forwarded-Proto", "https"); | ||
| 38 | // Never leak the executor.sh session cookie to the docs origin. | ||
| 39 | upstream.headers.delete("cookie"); | ||
| 40 | return upstream; | ||
| 41 | }; | ||
| 42 | 22 | ||
| 43 | export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( | 23 | export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( |
| 44 | ({ pathname, request, next }) => { | 24 | ({ pathname, request, next }) => { |
| 45 | if (!isDocsPath(pathname)) return next(); | 25 | if (!isDocsPath(pathname)) return next(); |
| 46 | return fetch(buildDocsUpstream(request)); | 26 | return docsProxyResponse(request, pathname); |
| 47 | }, | 27 | }, |
| 48 | ); | 28 | ); |
| @@ -1,11 +1,10 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Edge concerns — the analytics/marketing/docs request middlewares that run at | 2 | // Edge concerns — request middleware that runs before the app's own mcp + api |
| 3 | // the worker edge BEFORE the app's own mcp + api dispatch. None of these touch | 3 | // dispatch. These proxy or tunnel to external services without touching the |
| 4 | // the Effect app layer; they proxy or tunnel to external services (the | 4 | // Effect app layer. Marketing is dispatched even earlier, from server.ts, so a |
| 5 | // marketing worker, Sentry, PostHog, Mintlify docs). | 5 | // public page never loads the TanStack Start graph. |
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | export { marketingMiddleware } from "./marketing"; | ||
| 9 | export { sentryTunnelMiddleware } from "./sentry-tunnel"; | 8 | export { sentryTunnelMiddleware } from "./sentry-tunnel"; |
| 10 | export { posthogProxyMiddleware } from "./posthog"; | 9 | export { posthogProxyMiddleware } from "./posthog"; |
| 11 | export { docsProxyMiddleware } from "./docs"; | 10 | export { docsProxyMiddleware } from "./docs"; |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { isMarketingPath } from "./marketing"; | 3 | import { isMarketingPath, marketingProxyRequest } from "./marketing"; |
| 4 | 4 | ||
| 5 | // On executor.sh the marketing middleware proxies an allow-list of paths to the | 5 | // On executor.sh the marketing middleware proxies an allow-list of paths to the |
| 6 | // `executor-marketing` worker; everything else falls through to the auth-gated | 6 | // `executor-marketing` worker; everything else falls through to the auth-gated |
| @@ -12,6 +12,9 @@ describe("isMarketingPath", () => { · 5 unmodified lines | |||
| 12 | "/home", | 12 | "/home", |
| 13 | "/privacy", | 13 | "/privacy", |
| 14 | "/terms", | 14 | "/terms", |
| 15 | "/about-executor", | ||
| 16 | "/google-oauth", | ||
| 17 | "/google-workspace", | ||
| 15 | "/blog", | 18 | "/blog", |
| 16 | "/blog/", | 19 | "/blog/", |
| 17 | "/blog/some-post", | 20 | "/blog/some-post", |
| @@ -37,3 +40,54 @@ describe("isMarketingPath", () => { · 19 unmodified lines | |||
| 37 | }); | 40 | }); |
| 38 | } | 41 | } |
| 39 | }); | 42 | }); |
| 43 | |||
| 44 | describe("marketingProxyRequest", () => { | ||
| 45 | it("routes a signed-out homepage request", () => { | ||
| 46 | const request = new Request("https://executor.sh/?source=test"); | ||
| 47 | |||
| 48 | const proxied = marketingProxyRequest(request); | ||
| 49 | |||
| 50 | expect(proxied?.url).toBe("https://executor.sh/?source=test"); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("leaves the signed-in homepage with the cloud application", () => { | ||
| 54 | const request = new Request("https://executor.sh/", { | ||
| 55 | headers: { cookie: "other=value; wos-session=sealed" }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(marketingProxyRequest(request)).toBeNull(); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("routes public content even when a session cookie is present", () => { | ||
| 62 | const request = new Request("https://executor.sh/blog/post", { | ||
| 63 | headers: { cookie: "wos-session=sealed" }, | ||
| 64 | }); | ||
| 65 | |||
| 66 | expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("rewrites the public home alias to the marketing root", () => { | ||
| 70 | const request = new Request("https://executor.sh/home?source=test"); | ||
| 71 | |||
| 72 | expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("preserves the request method, headers, and body", async () => { | ||
| 76 | const request = new Request("https://executor.sh/_astro/_ph/capture", { | ||
| 77 | method: "POST", | ||
| 78 | headers: { "content-type": "application/json", "x-request-id": "request-1" }, | ||
| 79 | body: JSON.stringify({ event: "test" }), | ||
| 80 | }); | ||
| 81 | |||
| 82 | const proxied = marketingProxyRequest(request); | ||
| 83 | |||
| 84 | expect(proxied?.method).toBe("POST"); | ||
| 85 | expect(proxied?.headers.get("x-request-id")).toBe("request-1"); | ||
| 86 | await expect(proxied?.json()).resolves.toEqual({ event: "test" }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("does not proxy non-production hosts or app-owned paths", () => { | ||
| 90 | expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); | ||
| 91 | expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); | ||
| 92 | }); | ||
| 93 | }); | ||
| @@ -3,14 +3,10 @@ · 2 unmodified lines | |||
| 3 | // | 3 | // |
| 4 | // On the production domain (`executor.sh`), marketing paths and the | 4 | // On the production domain (`executor.sh`), marketing paths and the |
| 5 | // unauthenticated landing page are served by the separate `executor-marketing` | 5 | // unauthenticated landing page are served by the separate `executor-marketing` |
| 6 | // worker (bound as `env.MARKETING`). In local dev that worker isn't running, so | 6 | // worker. This module deliberately has no TanStack Start or cloud application |
| 7 | // unauthenticated visits fall through to the cloud app's routes (the sign-in | 7 | // imports: the Worker entry calls it before loading the Start server graph. |
| 8 | // page). | ||
| 9 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 10 | 9 | ||
| 11 | import { env } from "cloudflare:workers"; | ||
| 12 | import { createMiddleware } from "@tanstack/react-start"; | ||
| 13 | |||
| 14 | import { parseCookie } from "../auth/cookies"; | 10 | import { parseCookie } from "../auth/cookies"; |
| 15 | 11 | ||
| 16 | const MARKETING_PATHS = [ | 12 | const MARKETING_PATHS = [ |
| @@ -18,6 +14,9 @@ const MARKETING_PATHS = [ · 1 unmodified line | |||
| 18 | "/setup", | 14 | "/setup", |
| 19 | "/privacy", | 15 | "/privacy", |
| 20 | "/terms", | 16 | "/terms", |
| 17 | "/about-executor", | ||
| 18 | "/google-oauth", | ||
| 19 | "/google-workspace", | ||
| 21 | "/blog", | 20 | "/blog", |
| 22 | "/llms.txt", | 21 | "/llms.txt", |
| 23 | "/api/detect", | 22 | "/api/detect", |
| @@ -27,33 +26,25 @@ const MARKETING_PATHS = [ · 3 unmodified lines | |||
| 27 | "/pattern-graph-paper.svg", | 26 | "/pattern-graph-paper.svg", |
| 28 | ]; | 27 | ]; |
| 29 | 28 | ||
| 30 | export const isMarketingPath = (pathname: string) => | 29 | const SESSION_COOKIE = "wos-session"; |
| 31 | MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); | ||
| 32 | |||
| 33 | const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; | ||
| 34 | |||
| 35 | export const marketingMiddleware = createMiddleware({ type: "request" }).server( | ||
| 36 | async ({ pathname, request, next }) => { | ||
| 37 | // Only proxy to the marketing worker on the production domain. In local | ||
| 38 | // dev we don't run `executor-marketing`, so unauthenticated visits fall | ||
| 39 | // through to the cloud app's routes (which show the sign-in page). | ||
| 40 | const host = new URL(request.url).hostname; | ||
| 41 | if (host !== "executor.sh") return next(); | ||
| 42 | 30 | ||
| 43 | const shouldProxyToMarketing = | 31 | /** Whether an exact pathname belongs to the public marketing worker. */ |
| 44 | isMarketingPath(pathname) || | 32 | export const isMarketingPath = (pathname: string): boolean => |
| 45 | (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); | 33 | MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); |
| 46 | |||
| 47 | if (!shouldProxyToMarketing) return next(); | ||
| 48 | |||
| 49 | const marketing = getMarketingWorker(); | ||
| 50 | if (!marketing) return next(); | ||
| 51 | 34 | ||
| 52 | const url = new URL(request.url); | 35 | /** |
| 53 | // Rewrite /home to / so marketing worker serves its homepage | 36 | * Project a production request onto the marketing service-binding request. |
| 54 | if (pathname === "/home") { | 37 | * Returns `null` when the cloud application owns the request instead. |
| 55 | url.pathname = "/"; | 38 | */ |
| 56 | } | 39 | export const marketingProxyRequest = (request: Request): Request | null => { |
| 57 | return marketing.fetch(new Request(url, request)); | 40 | const url = new URL(request.url); |
| 58 | }, | 41 | if (url.hostname !== "executor.sh") return null; |
| 59 | ); | 42 | |
| 43 | const shouldProxy = | ||
| 44 | isMarketingPath(url.pathname) || | ||
| 45 | (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); | ||
| 46 | if (!shouldProxy) return null; | ||
| 47 | |||
| 48 | if (url.pathname === "/home") url.pathname = "/"; | ||
| 49 | return new Request(url, request); | ||
| 50 | }; | ||
| @@ -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 | /** | ||
84 more lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 436 lines aren’t shown here.
This diff is large: its 482 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 160 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 54 lines aren’t shown here.
This diff is large: its 69 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 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 270 lines aren’t shown here.
This diff is large: its 176 lines aren’t shown here.
This diff is large: its 194 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 9 lines aren’t shown here.
This diff is large: its 127 lines aren’t shown here.
This diff is large: its 182 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 41 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 38 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 98 lines aren’t shown here.
This diff is large: its 13 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 31 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 227 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 338 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 81 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 115 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 115 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 35 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 43 lines aren’t shown here.
This diff is large: its 211 lines aren’t shown here.
This diff is large: its 98 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 166 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 65 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 111 lines aren’t shown here.
This diff is large: its 56 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 81 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 85 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 198 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 84 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 661 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 219 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 388 lines aren’t shown here.
This diff is large: its 107 lines aren’t shown here.
This diff is large: its 257 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 108 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 80 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 39 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 293 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.
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 260 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 342 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 173 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 47 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 202 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 230 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 134 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 35 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 67 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 156 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 22 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 71 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 266 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 176 lines aren’t shown here.
This diff is large: its 30 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 118 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 142 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 618 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 772 lines aren’t shown here.
This diff is large: its 297 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 103 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 745 lines aren’t shown here.
This diff is large: its 203 lines aren’t shown here.
This diff is large: its 235 lines aren’t shown here.
This diff is large: its 104 lines aren’t shown here.
This diff is large: its 306 lines aren’t shown here.
This diff is large: its 2888 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 126 lines aren’t shown here.
This diff is large: its 184 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 236 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 340 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 1543 lines aren’t shown here.
This diff is large: its 691 lines aren’t shown here.
This diff is large: its 1057 lines aren’t shown here.
This diff is large: its 232 lines aren’t shown here.
This diff is large: its 353 lines aren’t shown here.
This diff is large: its 130 lines aren’t shown here.
This diff is large: its 1430 lines aren’t shown here.
This diff is large: its 407 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 117 lines aren’t shown here.
This diff is large: its 299 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 53 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 91 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 118 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 170 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 646 lines aren’t shown here.
This diff is large: its 866 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 177 lines aren’t shown here.
This diff is large: its 220 lines aren’t shown here.
This diff is large: its 266 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 91 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 2 lines aren’t shown here.
This diff is large: its 74 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 168 lines aren’t shown here.
This diff is large: its 337 lines aren’t shown here.
This diff is large: its 431 lines aren’t shown here.
This diff is large: its 360 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 223 lines aren’t shown here.
This diff is large: its 233 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 38 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 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 32 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 24 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 153 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 313 lines aren’t shown here.
This diff is large: its 196 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 190 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 59 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 106 lines aren’t shown here.
This diff is large: its 278 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 114 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 118 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 larger than a page shows: the files after these aren’t listed. Clone the repository to see all of it.